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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +236 -3
  4. package/bin/gotry-inner.js +66 -20
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +87 -2
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -1,74 +1,73 @@
1
- import type { ActionReceiptV2, BookingReadActionV2, BookingWorkspaceSnapshotV2 } from './contracts-v2.ts'
2
- import type { OfferCriteriaV1 } from './contracts.ts'
1
+ import type { ActionReceipt, BookingReadAction, BookingWorkspaceSnapshot, OfferCriteria } from './contracts.ts'
3
2
  import { createHash } from 'node:crypto'
4
3
 
5
4
  /** 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
5
+ export const MAX_HOTELS_PER_TASK = 5
6
+ export const MAX_OFFERS_PER_HOTEL_GENERATION = 3
7
+ export const MAX_OFFER_CHECKS_PER_HOTEL = 2
8
+ export const MAX_OFFER_QUERIES_PER_HOTEL = 2
10
9
 
11
- export type AvailabilityHotelStatusV2 = 'unvisited' | 'active' | 'negative' | 'confirmed' | 'inconclusive'
12
- export type AvailabilityGenerationSourceV2 =
10
+ export type AvailabilityHotelStatus = 'unvisited' | 'active' | 'negative' | 'confirmed' | 'inconclusive'
11
+ export type AvailabilityGenerationSource =
13
12
  | { kind: 'query_receipt'; actionId: string; receiptDigest: string; workspaceRevision: number }
14
13
  | { 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
14
+ export interface AvailabilityGeneration { generationId: string; source: AvailabilityGenerationSource; offerSetDigest: string; orderedOfferRefs: string[]; evidence: 'complete' | 'partial'; valid: boolean }
15
+ export interface AvailabilityHotelState {
16
+ hotelRef: string; status: AvailabilityHotelStatus; checksIssued: number; offerQueriesIssued: number; generationNo: number
17
+ currentGeneration?: AvailabilityGeneration
19
18
  /** Compatibility projection for callers needing current refs only. */
20
19
  generation: number; currentOfferRefs: string[]; invalidatedOfferRefs: string[]; checkCount: number; freshOffersRequired: boolean
21
20
  tombstonedOfferRefs: string[]; tombstonedOfferVersionRefs: string[]
22
21
  lastEvidence: 'none' | 'confirmed' | 'unavailable' | 'inconclusive'
23
22
  }
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 {
23
+ export interface AvailabilityAttempt { actionId: string; hotelRef: string; offerRef: string; offerVersionRef: string; generation: number; ordinal: number; workspaceRevision: number }
24
+ export interface AvailabilityQueryReservation { actionId: string; hotelRefs: string[]; workspaceRevision: number }
25
+ export interface AvailabilityExhaustion {
27
26
  code: 'availability_confirmed' | 'availability_exhausted_complete' | 'availability_exhausted_inconclusive'
28
27
  hotelRefs: string[]; reason: 'no_current_offers' | 'check_limit_reached' | 'confirmed'; evidence: 'conclusive' | 'inconclusive'
29
28
  }
30
- export interface AvailabilityPolicyStateV2 {
29
+ export interface AvailabilityPolicyState {
31
30
  initialized: boolean; recoveryStarted: boolean
32
31
  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
32
+ activeHotelOrdinal: number; hotelRefs: string[]; hotels: Record<string, AvailabilityHotelState>; attempts: AvailabilityAttempt[]; queryReservations: AvailabilityQueryReservation[]; terminal?: AvailabilityExhaustion
33
+ recoveryId?: string; criteria?: OfferCriteria; criteriaDigest?: string; candidateSetDigest?: string; lastQueryHotelRefs?: string[]; lastQueryCriteriaDigest?: string
35
34
  /** Receipt-authoritative mapping used when a shortlist ref is no longer loaded. */
36
35
  lastQueryOfferHotels?: Record<string, string>; lastQuerySourceActionId?: string; lastQuerySourceReceiptDigest?: string; lastQueryWorkspaceRevision?: number
37
36
  }
38
- export type AvailabilityPolicyDecisionV2 =
37
+ export type AvailabilityPolicyDecision =
39
38
  | { ok: true; hotelRef: string; offerRef: string; offerVersionRef: string; checkCount: number; generation: number }
40
39
  | { ok: false; code: 'hotel_unknown' | 'offer_not_loaded' | 'offers_refresh_required' | 'hotel_check_limit_reached' | 'availability_exhausted' | 'generation_invalid' | 'hotel_not_active' }
41
40
 
42
- type AvailabilityActionLikeV2 = { kind: BookingReadActionV2['kind']; actionId: string; input: Record<string, any>; expectedRevision?: number }
41
+ type AvailabilityActionLike = { kind: BookingReadAction['kind']; actionId: string; input: Record<string, any>; expectedRevision?: number }
43
42
 
44
43
  function unique(values: readonly string[]): string[] { return [...new Set(values)] }
45
- export function assertWorkspaceLoadedOfferRefsUniqueV2(workspace: BookingWorkspaceSnapshotV2): void {
44
+ export function assertWorkspaceLoadedOfferRefsUnique(workspace: BookingWorkspaceSnapshot): void {
46
45
  if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerRef)).size) throw new Error('availability_duplicate_offer_ref')
47
46
  if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerVersionRef)).size) throw new Error('availability_duplicate_offer_version_ref')
48
47
  }
49
48
  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[] {
49
+ export function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex') }
50
+ function workspaceDigest(workspace: BookingWorkspaceSnapshot): string { const { contextRef: _context, ...bound } = workspace as unknown as Record<string, unknown>; return digest(bound) }
51
+ function offerRefsForHotel(workspace: BookingWorkspaceSnapshot, hotelRef: string, selectedOfferRef?: string): string[] {
53
52
  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')
53
+ if (selectedOfferRef) return unique([selectedOfferRef, ...refs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION)
54
+ if (refs.length > MAX_OFFERS_PER_HOTEL_GENERATION) throw new Error('availability_offer_limit_exceeded')
56
55
  return refs
57
56
  }
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[] {
57
+ function loadedOfferVersion(workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string) { return workspace.loadedOffers.find((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef) }
58
+ function legalOfferRefsForHotel(workspace: BookingWorkspaceSnapshot, hotel: AvailabilityHotelState, refs: readonly string[]): string[] {
60
59
  const legal = refs.filter((ref) => {
61
60
  if (hotel.tombstonedOfferRefs.includes(ref)) return false
62
61
  const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === ref && offer.hotelRef === hotel.hotelRef)
63
62
  return Boolean(loaded && !hotel.tombstonedOfferVersionRefs.includes(loaded.offerVersionRef))
64
63
  })
65
- if (legal.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2) throw new Error('availability_offer_limit_exceeded')
64
+ if (legal.length > MAX_OFFERS_PER_HOTEL_GENERATION) throw new Error('availability_offer_limit_exceeded')
66
65
  return unique(legal)
67
66
  }
68
- function copyHotel(hotel: AvailabilityHotelStateV2): AvailabilityHotelStateV2 {
67
+ function copyHotel(hotel: AvailabilityHotelState): AvailabilityHotelState {
69
68
  return { ...hotel, currentOfferRefs: [...hotel.currentOfferRefs], invalidatedOfferRefs: [...hotel.invalidatedOfferRefs], tombstonedOfferRefs: [...hotel.tombstonedOfferRefs], tombstonedOfferVersionRefs: [...hotel.tombstonedOfferVersionRefs], ...(hotel.currentGeneration ? { currentGeneration: { ...hotel.currentGeneration, orderedOfferRefs: [...hotel.currentGeneration.orderedOfferRefs] } } : {}) }
70
69
  }
71
- function copyState(state: AvailabilityPolicyStateV2): AvailabilityPolicyStateV2 {
70
+ function copyState(state: AvailabilityPolicyState): AvailabilityPolicyState {
72
71
  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
72
  }
74
73
 
@@ -79,7 +78,7 @@ function isCriterion(value: unknown, valueCheck: (candidate: unknown) => boolean
79
78
  function isMoney(value: unknown): boolean {
80
79
  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
80
  }
82
- function isOfferCriteriaV2(value: unknown): value is OfferCriteriaV1 {
81
+ function isOfferCriteria(value: unknown): value is OfferCriteria {
83
82
  if (!isPlainRecord(value)) return false
84
83
  const allowed = ['roomType', 'bedType', 'meals', 'freeCancellation', 'freeCancellationUntil', 'totalPriceMax', 'roomsAvailableMin', 'payAtProperty', 'mobileRate', 'targetCount', 'sort']
85
84
  if (Object.keys(value).some((key) => !allowed.includes(key))) return false
@@ -97,21 +96,21 @@ function isOfferCriteriaV2(value: unknown): value is OfferCriteriaV1 {
97
96
  if (value.sort !== undefined && !['best_match', 'total_price_asc', 'cancellation_latest'].includes(value.sort as string)) return false
98
97
  return true
99
98
  }
100
- function bindCriteria(next: AvailabilityPolicyStateV2, criteriaDigest: string, criteria?: OfferCriteriaV1, required = false): void {
99
+ function bindCriteria(next: AvailabilityPolicyState, criteriaDigest: string, criteria?: OfferCriteria, required = false): void {
101
100
  if (criteria !== undefined) {
102
- if (!isOfferCriteriaV2(criteria) || digestV2(criteria) !== criteriaDigest) throw new Error('availability_criteria_invalid')
101
+ if (!isOfferCriteria(criteria) || digest(criteria) !== criteriaDigest) throw new Error('availability_criteria_invalid')
103
102
  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')
103
+ if (next.criteria && digest(next.criteria) !== criteriaDigest) throw new Error('availability_criteria_changed')
105
104
  next.criteria = structuredClone(criteria); next.criteriaDigest = criteriaDigest
106
105
  } 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')
106
+ else if (next.criteria && next.criteriaDigest !== digest(next.criteria)) throw new Error('availability_criteria_mismatch')
108
107
  else if (!next.criteria && criteriaDigest) {
109
108
  if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
110
109
  next.criteriaDigest = criteriaDigest
111
110
  }
112
111
  if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest && criteriaDigest) throw new Error('availability_criteria_changed')
113
112
  }
114
- function terminalFor(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined {
113
+ function terminalFor(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined {
115
114
  if (!state.recoveryStarted || !state.hotelRefs.length) return undefined
116
115
  const hotels = state.hotelRefs.map((ref) => state.hotels[ref]!)
117
116
  if (hotels.some((hotel) => hotel.status === 'confirmed')) return { code: 'availability_confirmed', hotelRefs: [...state.hotelRefs], reason: 'confirmed', evidence: 'conclusive' }
@@ -119,19 +118,19 @@ function terminalFor(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2
119
118
  // The first manual check starts with a workspace generation (zero recovery
120
119
  // queries), so it must leave a fresh-query transition available.
121
120
  if (hotels.some((hotel) =>
122
- hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2
121
+ hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL
123
122
  && (
124
123
  hotel.status === 'active'
125
124
  || hotel.status === 'unvisited'
126
- || (hotel.freshOffersRequired && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2)
125
+ || (hotel.freshOffersRequired && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL)
127
126
  )
128
127
  )) return undefined
129
128
  const conclusive = hotels.every((hotel) => hotel.status === 'negative')
130
129
  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
130
  }
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' {
131
+ function withTerminal(state: AvailabilityPolicyState): AvailabilityPolicyState { const terminal = terminalFor(state); return terminal ? { ...state, terminal, availabilityPhase: 'terminal' } : state }
132
+ function newHotel(hotelRef: string): AvailabilityHotelState { return { hotelRef, status: 'unvisited', checksIssued: 0, offerQueriesIssued: 0, generationNo: 0, generation: 0, currentOfferRefs: [], invalidatedOfferRefs: [], checkCount: 0, freshOffersRequired: false, tombstonedOfferRefs: [], tombstonedOfferVersionRefs: [], lastEvidence: 'none' } }
133
+ function phaseForActiveHotel(state: AvailabilityPolicyState): 'need_offers' | 'need_check' {
135
134
  const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal] ?? '']
136
135
  return active?.status === 'active' && active.currentGeneration?.valid && active.currentOfferRefs.length > 0 && !active.freshOffersRequired
137
136
  ? 'need_check'
@@ -139,12 +138,12 @@ function phaseForActiveHotel(state: AvailabilityPolicyStateV2): 'need_offers' |
139
138
  }
140
139
 
141
140
  /** 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: [] } }
141
+ export function createAvailabilityPolicy(workspace: BookingWorkspaceSnapshot): AvailabilityPolicyState { assertWorkspaceLoadedOfferRefsUnique(workspace); return { initialized: false, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] } }
143
142
  /** Ordinary search results never get silently truncated into recovery candidates. */
144
- export function recordVisibleHotelsV2(state: AvailabilityPolicyStateV2, _workspace: BookingWorkspaceSnapshotV2): AvailabilityPolicyStateV2 { return copyState(state) }
143
+ export function recordVisibleHotels(state: AvailabilityPolicyState, _workspace: BookingWorkspaceSnapshot): AvailabilityPolicyState { return copyState(state) }
145
144
  /** 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)
145
+ export function recordObservedOffersQuery(state: AvailabilityPolicyState, hotelRefs: readonly string[], criteriaDigest: string, workspace?: BookingWorkspaceSnapshot, receipt?: ActionReceipt, sourceActionId?: string, receiptDigest?: string, criteria?: OfferCriteria): AvailabilityPolicyState {
146
+ if (workspace) assertWorkspaceLoadedOfferRefsUnique(workspace)
148
147
  if (state.recoveryStarted) return copyState(state)
149
148
  const next = copyState(state)
150
149
  if (!workspace || !receipt) return next
@@ -163,10 +162,10 @@ export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, ho
163
162
  delete next.criteria
164
163
  delete next.criteriaDigest
165
164
  bindCriteria(next, criteriaDigest, criteria)
166
- if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !== digestV2(receipt)) throw new Error('availability_query_provenance_mismatch')
165
+ if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !== digest(receipt)) throw new Error('availability_query_provenance_mismatch')
167
166
  next.lastQueryHotelRefs = [...new Set(hotelRefs)]
168
167
  next.lastQueryCriteriaDigest = criteriaDigest || undefined
169
- next.lastQueryOfferHotels = validateOffersReceiptWorkspaceV2(receipt, workspace, hotelRefs)
168
+ next.lastQueryOfferHotels = validateOffersReceiptWorkspace(receipt, workspace, hotelRefs)
170
169
  next.lastQuerySourceActionId = sourceActionId
171
170
  next.lastQuerySourceReceiptDigest = receiptDigest
172
171
  next.lastQueryWorkspaceRevision = workspace.revision
@@ -186,8 +185,8 @@ export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, ho
186
185
  * the authoritative loaded offers; partial/gap receipts may only describe an
187
186
  * evidenced subset. An unreported offer is never silently admitted.
188
187
  */
189
- export function validateOffersReceiptWorkspaceV2(receipt: ActionReceiptV2, workspace: BookingWorkspaceSnapshotV2, hotelRefs: readonly string[]): Record<string, string> {
190
- assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
188
+ export function validateOffersReceiptWorkspace(receipt: ActionReceipt, workspace: BookingWorkspaceSnapshot, hotelRefs: readonly string[]): Record<string, string> {
189
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
191
190
  if (receipt.observation.kind !== 'offers.state') throw new Error('availability_offers_observation_required')
192
191
  const requested = unique(hotelRefs)
193
192
  if (!sameSet(receipt.observation.hotelRefs, requested)) throw new Error('availability_offers_hotel_mismatch')
@@ -210,13 +209,13 @@ export function validateOffersReceiptWorkspaceV2(receipt: ActionReceiptV2, works
210
209
  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
210
 
212
211
  /** 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)
212
+ export function recordOffersQueryIssued(state: AvailabilityPolicyState, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshot, actionId: string, criteriaDigest = '', criteria?: OfferCriteria): AvailabilityPolicyState {
213
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
215
214
  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')
215
+ if (!requested.length || requested.length > MAX_HOTELS_PER_TASK) throw new Error('availability_hotel_limit_exceeded')
217
216
  if (!state.recoveryStarted) throw new Error('availability_recovery_not_started')
218
217
  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')
218
+ if (requested.some((ref) => state.hotels[ref]?.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL)) throw new Error('availability_offer_query_limit_reached')
220
219
  throw new Error('availability_terminal')
221
220
  }
222
221
  if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
@@ -227,23 +226,23 @@ export function recordOffersQueryIssuedV2(state: AvailabilityPolicyStateV2, hote
227
226
  if (!sameSet(existingReservation.hotelRefs, requested) || existingReservation.workspaceRevision !== workspace.revision) throw new Error('availability_query_action_conflict')
228
227
  return next
229
228
  }
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 }
229
+ for (const hotelRef of requested) { const hotel = next.hotels[hotelRef]!; if (hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL) throw new Error('availability_offer_query_limit_reached'); hotel.offerQueriesIssued += 1 }
231
230
  next.queryReservations.push({ actionId, hotelRefs: [...requested], workspaceRevision: workspace.revision }); next.availabilityPhase = 'waiting_offers'; next.initialized = true; return next
232
231
  }
233
- export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshotV2, actionId: string, receiptDigest: string, receipt: ActionReceiptV2, criteriaDigest = '', criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
234
- assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
232
+ export function recordOffersGeneration(state: AvailabilityPolicyState, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshot, actionId: string, receiptDigest: string, receipt: ActionReceipt, criteriaDigest = '', criteria?: OfferCriteria): AvailabilityPolicyState {
233
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
235
234
  if (!state.recoveryStarted) return copyState(state)
236
235
  if (state.terminal) throw new Error('availability_terminal')
237
236
  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')
237
+ if (!requested.length || requested.length > MAX_HOTELS_PER_TASK) throw new Error('availability_hotel_limit_exceeded')
239
238
  const reservation = next.queryReservations.find((query) => query.actionId === actionId)
240
239
  if (!reservation || !sameSet(reservation.hotelRefs, requested)) throw new Error('availability_query_not_reserved')
241
240
  if (receipt.actionId !== actionId) throw new Error('availability_query_receipt_mismatch')
242
- if (receiptDigest !== digestV2(receipt)) throw new Error('availability_receipt_digest_mismatch')
241
+ if (receiptDigest !== digest(receipt)) throw new Error('availability_receipt_digest_mismatch')
243
242
  if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
244
243
  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
244
  bindCriteria(next, criteriaDigest, criteria)
246
- if (receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspaceV2(receipt, workspace, requested)
245
+ if (receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspace(receipt, workspace, requested)
247
246
  for (const hotelRef of requested) {
248
247
  const hotel = next.hotels[hotelRef]!
249
248
  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))
@@ -254,11 +253,11 @@ export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotel
254
253
  : offerRefsForHotel(workspace, hotelRef)
255
254
  const refs = legalOfferRefsForHotel(workspace, hotel, candidateRefs)
256
255
  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 }
256
+ if (unusable) { hotel.currentOfferRefs = []; hotel.invalidatedOfferRefs = [...(hotel.currentGeneration?.orderedOfferRefs ?? [])]; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL; hotel.status = 'inconclusive'; hotel.lastEvidence = 'inconclusive'; continue }
258
257
  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 }
258
+ const noLegalRefsNeedsRefresh = filteredAllCandidates && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL
259
+ 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 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL; hotel.status = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'active' : 'negative'; hotel.lastEvidence = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'none' : 'unavailable'
260
+ hotel.currentGeneration = { generationId: `${hotelRef}:generation:${hotel.generationNo}`, source: { kind: 'query_receipt', actionId, receiptDigest, workspaceRevision: workspace.revision }, offerSetDigest: digest(refs), orderedOfferRefs: refs, evidence: partial ? 'partial' : 'complete', valid: refs.length > 0 && !partial }
262
261
  }
263
262
  const firstRequested = next.hotelRefs.indexOf(requested[0]!)
264
263
  if (firstRequested >= 0) next.activeHotelOrdinal = firstRequested
@@ -271,8 +270,8 @@ export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotel
271
270
  next.initialized = true; next.availabilityPhase = phaseForActiveHotel(next); delete next.terminal; return withTerminal(next)
272
271
  }
273
272
 
274
- export function canIssueOfferCheckV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string): AvailabilityPolicyDecisionV2 {
275
- assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
273
+ export function canIssueOfferCheck(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string): AvailabilityPolicyDecision {
274
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
276
275
  // Availability recovery is opt-in at the typed offers.query seam. Existing
277
276
  // v2 read flows retain their pre-recovery offer.check behavior.
278
277
  if (!state.recoveryStarted) {
@@ -288,12 +287,12 @@ export function canIssueOfferCheckV2(state: AvailabilityPolicyStateV2, workspace
288
287
  if (hotel.freshOffersRequired) return { ok: false, code: 'offers_refresh_required' }
289
288
  if (hotel.tombstonedOfferRefs.includes(offerRef) || hotel.tombstonedOfferVersionRefs.includes(offerVersionRef)) return { ok: false, code: 'generation_invalid' }
290
289
  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' }
290
+ if (hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL) return { ok: false, code: 'hotel_check_limit_reached' }
292
291
  return { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: hotel.checksIssued + 1, generation: hotel.generationNo }
293
292
  }
294
293
  /** 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)
294
+ function startRecoveryAtOffer(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, actionId: string): AvailabilityPolicyState {
295
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
297
296
  const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === offerRef)
298
297
  if (!loaded) return copyState(state)
299
298
  const shortlistHotels = workspace.shortlistedOfferRefs.map((ref) => workspace.loadedOffers.find((offer) => offer.offerRef === ref)?.hotelRef ?? state.lastQueryOfferHotels?.[ref]).map((ref, index) => {
@@ -301,8 +300,8 @@ function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: Boo
301
300
  return ref
302
301
  })
303
302
  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)]))
303
+ const candidates = unique([loaded.hotelRef, ...fallbackHotels]).slice(0, MAX_HOTELS_PER_TASK)
304
+ const next = copyState(state); next.recoveryStarted = true; next.recoveryId = `recovery:${actionId}`; next.hotelRefs = candidates; next.candidateSetDigest = digest(candidates); next.hotels = Object.fromEntries(candidates.map((ref) => [ref, newHotel(ref)]))
306
305
  const nextHotel = next.hotels[loaded.hotelRef]!
307
306
  const mappedRefs = state.lastQueryOfferHotels ? Object.entries(state.lastQueryOfferHotels).filter(([, hotel]) => hotel === loaded.hotelRef).map(([ref]) => ref) : []
308
307
  const currentRefs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === loaded.hotelRef).map((offer) => offer.offerRef))
@@ -310,24 +309,24 @@ function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: Boo
310
309
  && state.lastQueryWorkspaceRevision === workspace.revision
311
310
  && Boolean(state.lastQuerySourceActionId && state.lastQuerySourceReceiptDigest)
312
311
  && sameSet(mappedRefs, currentRefs)
313
- if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest = digestV2(next.criteria) }
312
+ if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest = digest(next.criteria) }
314
313
  const refs = mapped && state.lastQueryOfferHotels
315
- ? unique([offerRef, ...mappedRefs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION_V2)
314
+ ? unique([offerRef, ...mappedRefs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION)
316
315
  : offerRefsForHotel(workspace, loaded.hotelRef, offerRef)
317
316
  const source = mapped
318
317
  ? { 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
318
+ : { kind: 'workspace_snapshot' as const, workspaceDigest: workspaceDigest(workspace), workspaceRevision: workspace.revision }
319
+ nextHotel.generationNo = 1; nextHotel.generation = 1; nextHotel.currentOfferRefs = refs; nextHotel.status = refs.length ? 'active' : 'inconclusive'; nextHotel.currentGeneration = { generationId: `${loaded.hotelRef}:generation:1`, source, offerSetDigest: digest(refs), orderedOfferRefs: refs, evidence: 'complete', valid: refs.length > 0 }; next.initialized = true; next.availabilityPhase = 'need_check'; return next
321
320
  }
322
- export function recordOfferCheckIssuedV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string, actionId = `offer-check-${offerRef}-${state.attempts.length + 1}`): AvailabilityPolicyStateV2 {
323
- assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
321
+ export function recordOfferCheckIssued(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string, actionId = `offer-check-${offerRef}-${state.attempts.length + 1}`): AvailabilityPolicyState {
322
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
324
323
  if (state.terminal) throw new Error('availability_terminal')
325
324
  if (!state.recoveryStarted) {
326
325
  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)
326
+ const started = startRecoveryAtOffer(state, workspace, offerRef, actionId)
327
+ return recordOfferCheckIssued(started, workspace, offerRef, offerVersionRef, actionId)
329
328
  }
330
- const decision = canIssueOfferCheckV2(state, workspace, offerRef, offerVersionRef); if (!decision.ok) throw new Error(`availability_${decision.code}`)
329
+ const decision = canIssueOfferCheck(state, workspace, offerRef, offerVersionRef); if (!decision.ok) throw new Error(`availability_${decision.code}`)
331
330
  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
331
  }
333
332
 
@@ -336,27 +335,27 @@ export function recordOfferCheckIssuedV2(state: AvailabilityPolicyStateV2, works
336
335
  * next to the receipt reducers makes ledger replay use precisely the same
337
336
  * transition as the write path.
338
337
  */
339
- export function reduceAvailabilityActionV2(
340
- state: AvailabilityPolicyStateV2,
341
- workspace: BookingWorkspaceSnapshotV2,
342
- action: AvailabilityActionLikeV2,
343
- ): AvailabilityPolicyStateV2 {
338
+ export function reduceAvailabilityAction(
339
+ state: AvailabilityPolicyState,
340
+ workspace: BookingWorkspaceSnapshot,
341
+ action: AvailabilityActionLike,
342
+ ): AvailabilityPolicyState {
344
343
  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)
344
+ if (!isOfferCriteria(action.input.criteria) && (!state.criteria || !isOfferCriteria(state.criteria))) throw new Error('availability_criteria_required')
345
+ if (!isOfferCriteria(action.input.criteria) && state.recoveryStarted) throw new Error('availability_criteria_required')
346
+ if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
347
+ const criteriaDigest = digest(action.input.criteria)
348
+ let next = recordObservedOffersQuery(state, action.input.hotelRefs, criteriaDigest)
349
+ if (state.recoveryStarted) next = recordOffersQueryIssued(next, action.input.hotelRefs, workspace, action.actionId, criteriaDigest, action.input.criteria)
351
350
  return next
352
351
  }
353
- if (action.kind === 'offer.check') return recordOfferCheckIssuedV2(state, workspace, action.input.offerRef, action.input.offerVersionRef, action.actionId)
352
+ if (action.kind === 'offer.check') return recordOfferCheckIssued(state, workspace, action.input.offerRef, action.input.offerVersionRef, action.actionId)
354
353
  return copyState(state)
355
354
  }
356
355
 
357
356
  /** 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)
357
+ export function recordOfferCheckReceipt(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, receipt: ActionReceipt, actionId: string, offerRef: string, offerVersionRef: string, expectedRevision: number): AvailabilityPolicyState {
358
+ assertWorkspaceLoadedOfferRefsUnique(workspace)
360
359
  if (!state.recoveryStarted) return copyState(state)
361
360
  const attempt = state.attempts.find((candidate) => candidate.actionId === actionId); if (!attempt) throw new Error('availability_attempt_unknown')
362
361
  if (receipt.actionId !== actionId) throw new Error('availability_attempt_mismatch')
@@ -381,7 +380,7 @@ export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, work
381
380
  hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
382
381
  hotel.tombstonedOfferRefs = unique([...hotel.tombstonedOfferRefs, offerRef])
383
382
  }
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
383
+ hotel.currentGeneration.valid = false; hotel.invalidatedOfferRefs = [...hotel.currentGeneration.orderedOfferRefs]; hotel.currentOfferRefs = []; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL
385
384
  hotel.status = completeNegative ? 'negative' : 'inconclusive'; hotel.lastEvidence = completeNegative ? 'unavailable' : 'inconclusive'
386
385
  const currentOrdinal = next.hotelRefs.indexOf(attempt.hotelRef)
387
386
  const nextOrdinal = hotel.freshOffersRequired ? currentOrdinal : next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
@@ -391,35 +390,35 @@ export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, work
391
390
 
392
391
  /** Fold the availability portion of a receipt using the same pure reducer as
393
392
  * 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)
393
+ export function reduceAvailabilityReceipt(
394
+ state: AvailabilityPolicyState,
395
+ workspace: BookingWorkspaceSnapshot,
396
+ receipt: ActionReceipt,
397
+ action: AvailabilityActionLike,
398
+ ): AvailabilityPolicyState {
399
+ let next = recordVisibleHotels(state, workspace)
401
400
  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)
401
+ if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
402
+ next = recordObservedOffersQuery(next, action.input.hotelRefs, digest(action.input.criteria), workspace, receipt, action.actionId, digest(receipt), action.input.criteria)
404
403
  } 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)
404
+ if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
405
+ next = recordOffersGeneration(next, action.input.hotelRefs, workspace, action.actionId, digest(receipt), receipt, state.criteriaDigest ?? '', action.input.criteria)
407
406
  } else if (action.kind === 'offer.check') {
408
407
  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)
408
+ next = recordOfferCheckReceipt(next, workspace, receipt, action.actionId, action.input.offerRef, action.input.offerVersionRef, action.expectedRevision)
410
409
  }
411
410
  return next
412
411
  }
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 {
412
+ export function availabilityPolicyIsTerminal(state: AvailabilityPolicyState): boolean { return Boolean(state.terminal) }
413
+ export function availabilityPolicyResult(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined { return state.terminal ? { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } : undefined }
414
+ export function validateAvailabilityPolicy(state: AvailabilityPolicyState): boolean {
416
415
  try {
417
416
  if (!state || typeof state.initialized !== 'boolean' || typeof state.recoveryStarted !== 'boolean') return false
418
417
  if (state.recoveryId !== undefined && (typeof state.recoveryId !== 'string' || !state.recoveryId)) return false
419
418
  if (state.criteriaDigest !== undefined && (typeof state.criteriaDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.criteriaDigest))) return false
420
419
  if (state.candidateSetDigest !== undefined && (typeof state.candidateSetDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.candidateSetDigest))) return false
421
420
  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
421
+ if (!Number.isSafeInteger(state.activeHotelOrdinal) || !Array.isArray(state.hotelRefs) || state.hotelRefs.length > MAX_HOTELS_PER_TASK) return false
423
422
  if (state.hotelRefs.some((ref) => typeof ref !== 'string' || !ref) || new Set(state.hotelRefs).size !== state.hotelRefs.length) return false
424
423
  if ((state.hotelRefs.length && (state.activeHotelOrdinal < 0 || state.activeHotelOrdinal >= state.hotelRefs.length)) || (!state.hotelRefs.length && state.activeHotelOrdinal !== 0)) return false
425
424
  if (!state.hotels || typeof state.hotels !== 'object' || Object.keys(state.hotels).sort().join('\0') !== [...state.hotelRefs].sort().join('\0')) return false
@@ -427,7 +426,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
427
426
  if (!state.recoveryStarted) {
428
427
  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
428
  if (state.criteria !== undefined || state.criteriaDigest !== undefined) {
430
- if (!isOfferCriteriaV2(state.criteria) || !state.criteriaDigest || state.criteriaDigest !== digestV2(state.criteria)) return false
429
+ if (!isOfferCriteria(state.criteria) || !state.criteriaDigest || state.criteriaDigest !== digest(state.criteria)) return false
431
430
  }
432
431
  if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
433
432
  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
@@ -436,8 +435,8 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
436
435
  if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
437
436
  return state.initialized === false && state.availabilityPhase === 'need_offers'
438
437
  }
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)
438
+ if (!state.initialized || !state.recoveryId || !state.candidateSetDigest || state.candidateSetDigest !== digest(state.hotelRefs)) return false
439
+ const criteriaBound = isOfferCriteria(state.criteria) && Boolean(state.criteriaDigest) && state.criteriaDigest === digest(state.criteria)
441
440
  const manualCheckWorkspaceSnapshot = state.attempts.length > 0 && state.attempts.every((attempt) => state.hotels[attempt.hotelRef]?.currentGeneration?.source.kind === 'workspace_snapshot')
442
441
  const manualCheckMayBindCriteria = !state.criteria && !state.criteriaDigest && state.queryReservations.length === 0 && manualCheckWorkspaceSnapshot && (
443
442
  ['waiting_check', 'need_offers'].includes(state.availabilityPhase)
@@ -451,29 +450,29 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
451
450
  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
451
  if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
453
452
  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'])
453
+ const validStatus = new Set<AvailabilityHotelStatus>(['unvisited', 'active', 'negative', 'confirmed', 'inconclusive'])
454
+ const validEvidence = new Set<AvailabilityHotelState['lastEvidence']>(['none', 'confirmed', 'unavailable', 'inconclusive'])
456
455
  for (const ref of state.hotelRefs) {
457
456
  const hotel = state.hotels[ref]
458
457
  if (!hotel || hotel.hotelRef !== ref || !validStatus.has(hotel.status) || !validEvidence.has(hotel.lastEvidence)) return false
459
458
  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
459
+ if (hotel.checksIssued < 0 || hotel.checksIssued > MAX_OFFER_CHECKS_PER_HOTEL || hotel.offerQueriesIssued < 0 || hotel.offerQueriesIssued > MAX_OFFER_QUERIES_PER_HOTEL || hotel.generationNo < 0 || hotel.generation !== hotel.generationNo || hotel.checkCount !== hotel.checksIssued) return false
461
460
  if (typeof hotel.freshOffersRequired !== 'boolean' || !Array.isArray(hotel.currentOfferRefs) || !Array.isArray(hotel.invalidatedOfferRefs) || !Array.isArray(hotel.tombstonedOfferRefs) || !Array.isArray(hotel.tombstonedOfferVersionRefs)) return false
462
461
  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
462
+ 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 || hotel.invalidatedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION || hotel.currentOfferRefs.some((offer) => hotel.invalidatedOfferRefs.includes(offer) || hotel.tombstonedOfferRefs.includes(offer))) return false
464
463
  const generation = hotel.currentGeneration
465
464
  if (hotel.generationNo === 0) {
466
465
  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
466
  continue
468
467
  }
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
468
+ if (!generation || generation.generationId !== `${ref}:generation:${hotel.generationNo}` || !/^[0-9a-f]{64}$/.test(generation.offerSetDigest) || generation.offerSetDigest !== digest(generation.orderedOfferRefs) || !Array.isArray(generation.orderedOfferRefs) || generation.orderedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION || 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
469
+ if (generation.offerSetDigest !== digest(generation.orderedOfferRefs) || hotel.currentOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer)) || hotel.invalidatedOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer))) return false
471
470
  const source = generation.source
472
471
  if (!source || !['query_receipt', 'workspace_snapshot'].includes(source.kind) || !Number.isSafeInteger(source.workspaceRevision) || source.workspaceRevision < 0) return false
473
472
  if (source.kind === 'query_receipt' && (typeof source.actionId !== 'string' || !source.actionId || !/^[0-9a-f]{64}$/.test(source.receiptDigest))) return false
474
473
  if (source.kind === 'workspace_snapshot' && (typeof source.workspaceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(source.workspaceDigest))) return false
475
474
  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
475
+ if (hotel.freshOffersRequired && (hotel.currentOfferRefs.length !== 0 || hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL || hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL)) return false
477
476
  if (hotel.status === 'unvisited' || hotel.status === 'negative') {
478
477
  if (hotel.status === 'unvisited' || hotel.lastEvidence !== 'unavailable' || hotel.currentOfferRefs.length || generation.valid) return false
479
478
  }
@@ -483,7 +482,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
483
482
  }
484
483
  const attempts = state.attempts
485
484
  if (new Set(attempts.map((attempt) => attempt.actionId)).size !== attempts.length) return false
486
- const attemptsByHotel = new Map<string, AvailabilityAttemptV2[]>()
485
+ const attemptsByHotel = new Map<string, AvailabilityAttempt[]>()
487
486
  for (const attempt of attempts) {
488
487
  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
488
  const hotel = state.hotels[attempt.hotelRef]!
@@ -499,7 +498,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
499
498
  if (new Set(state.queryReservations.map((reservation) => reservation.actionId)).size !== state.queryReservations.length) return false
500
499
  const reservedHotels = new Set<string>()
501
500
  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
501
+ if (typeof reservation.actionId !== 'string' || !reservation.actionId || !Array.isArray(reservation.hotelRefs) || !reservation.hotelRefs.length || reservation.hotelRefs.length > MAX_HOTELS_PER_TASK || 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
502
  reservation.hotelRefs.forEach((ref) => reservedHotels.add(ref))
504
503
  }
505
504
  if (state.availabilityPhase === 'waiting_offers' && state.queryReservations.length === 0) return false
@@ -513,11 +512,11 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
513
512
  if (state.availabilityPhase !== 'terminal' && state.terminal) return false
514
513
  const recomputedTerminal = terminalFor(state)
515
514
  if (state.terminal) {
516
- if (!recomputedTerminal || digestV2(state.terminal) !== digestV2(recomputedTerminal)) return false
515
+ if (!recomputedTerminal || digest(state.terminal) !== digest(recomputedTerminal)) return false
517
516
  } else if (recomputedTerminal && !['waiting_offers', 'waiting_check'].includes(state.availabilityPhase)) return false
518
517
  return true
519
518
  } catch {
520
519
  return false
521
520
  }
522
521
  }
523
- export function availabilityPolicyActionIsRelevantV2(action: BookingReadActionV2): boolean { return action.kind === 'offers.query' || action.kind === 'offer.check' }
522
+ export function availabilityPolicyActionIsRelevant(action: BookingReadAction): boolean { return action.kind === 'offers.query' || action.kind === 'offer.check' }