Offers are not one thing
The word “offer” is reused for several different shapes. They are genuinely different primitives, and treating them as one is the most common mistake in this area. Keep them apart.
The word “package” is overloaded too, and it means one of two completely different primitives:
- A sourced package is a single product from a single provider. A TUI sun package is one accommodation plus its flights, priced and booked as one unit upstream. The
PackageOfferroutes serve this. - A composed FIT trip is a Trip Envelope made of independently-sourced components: a hotel from one provider, a flight from another, a transfer from a third, each committed on its own terms.
AvailabilityCandidate.candidateRef is stable within one search response only. It is not replay-safe for booking. The composer persists candidates as resumable trip state and re-resolves the candidate’s selection against the provider before reserve. Treat candidateRef as a correlation handle, never a booking token.Providers and the live fan-out
A provider here is aSourceAdapter (@voyant-travel/catalog-contracts/adapter/source-adapter), one instance per upstream connection. No implementer is privileged; Voyant Connect is one adapter among many, and a wholesaler, cruise line, or operator can build their own against the same contract.
Each adapter declares an AdapterCapabilities record. Two fields gate live search: verticals (the verticals this adapter feeds, for example ["accommodations"]) and the optional supportsAvailabilitySearch flag that gates the searchAvailability method. Adapters are keyed by connection_id in the SourceAdapterRegistry (createSourceAdapterRegistry, @voyant-travel/catalog/booking-engine/registry), so two connections of the same kind get two distinct entries with their own credentials.
fanOutAvailabilitySearch (@voyant-travel/catalog/search/availability-fan-out) is the cross-vertical search primitive. Given one AvailabilitySearchRequest scoped to a single vertical, it:
- Gates by vertical. An adapter whose
verticalsdo not include the requested vertical is skipped, not queried with criteria it does not own. - Gates by capability. An adapter without
supportsAvailabilitySearch(or without the method) is skipped and flaggedcapability_missing. - Enforces a per-connection hard timeout (default 5000ms). A slow source is reported
timeout; the rest still return on time. - Merges and ranks by price across every responding source into one
AvailabilityCandidatelist. - Never lets partial failure tank the search. Each source’s outcome lands in
AvailabilityConnectionResult[], with a status ofok,partial,empty,unsupported,timeout,error,capability_missing, orvertical_skipped.
OwnedAvailabilitySearchHandler registered in the OwnedAvailabilitySearchHandlerRegistry (@voyant-travel/catalog/search/owned-search-handler) claims exactly one vertical (its entityModule) and is treated by the fan-out exactly like a sourced adapter. Owned and sourced supply land in the same ranked list. Being registered is an owned handler’s capability declaration; unlike a sourced adapter, it is always search-capable.
Flights pre-date this generic fan-out and keep their own connector contract and search. fanOutFlightSearch (@voyant-travel/flights) produces MergedFlightOffer results, and mergedFlightOfferToCandidate (@voyant-travel/flights/orchestration/availability-bridge) bridges those into the same AvailabilityCandidate shape so flights rank alongside everything else. This is a result-shape mapping, not proof that flights satisfy the generic source-adapter contract.
The AvailabilityCandidate carries a public-safe price for ranking and display, an entity_module / entity_id pair, a vertical-shaped selection (what is needed to re-resolve and pin the exact candidate at reserve), and an optional source ({ kind: "sourced", connectionId } or { kind: "owned", module }) so a pick routes back to the right origin. Internal economics (net cost, margin, supplier ref) live under providerData, an opaque round-trip that is never serialized into a public DTO.
The design is captured in ADR-0006 and the dynamic-packaging RFC.
Composing a package
A composed FIT trip is built in the trips module, which adds three primitives on top of the deterministic envelope:tripRequirementsis an unresolved need: averticalpluscriteriaplus acriteriaVersion. Its status walksopentosourcingtocandidates_readytoselected, or tono_availabilitywhen a search returns nothing.tripCandidatesare persisted, rankedAvailabilityCandidaterows under a requirement, each with a TTL. Status isranked,selected,expired, ordiscarded.tripComponentsare the independently-committed parts of the envelope. A pinned component carries itsentityModule,sourceConnectionId, and the candidate’sselectionandcandidateRefin metadata.
service-requirements.ts:
addRequirementrecords the unresolved need on the envelope.sourceRequirementCandidates(db, input, deps)runs the fan-out and persists the ranked candidates. The fan-out is injected throughdeps.search. Trips never imports a catalog adapter; the deployment wires the search and hands it in, keeping trips deterministic and provider-agnostic.selectCandidatepins the chosen candidate into a draftcatalog_bookingcomponent. It enforces selected-uniqueness: picking one demotes any previously-selected candidate for that requirement.reshopRequirementandreshopTripclear a prior selection and re-source candidates when prices or availability go stale.expireStaleTripCandidatesis a TTL reaper, intended to be driven by a deployment cron, that marks elapsedrankedcandidatesexpired.
priceTrip re-resolves the selection (because candidateRef is not replay-safe), and reserveTrip re-validates before any supplier dispatch. Reserve calls assertEnvelopeRequirementsSatisfied as a gate: every required requirement must be selected before the envelope can reserve.
The admin routes are in @voyant-travel/trips/routes (relative paths under the trips mount):
POST /:envelopeId/requirementsandGET /:envelopeId/requirementsPOST /requirements/:requirementId/candidates(source candidates)POST /requirements/:requirementId/selectPOST /requirements/:requirementId/reshopPOST /:envelopeId/reshop
What is wired today
Be honest about the seam between the contract and a running deployment.- The generic fan-out is not wired into the operator application. The trip requirement-sourcing routes (
candidates, bothreshoppaths) resolvesourceCandidatesDeps, and when no deployment has injected the fan-out search dependency they return 501. There is no turnkey operator surface for generic cross-vertical dynamic packaging yet. The primitives, the contract, and the service are in place; the wiring is the deployment’s job. - The live
PackageOfferroutes are wired. The operator application mountscreateCatalogOffersAdminRoutes(createCatalogOffersAdminRoutesForOperator), so the sourced-package surface (package-offers,package-search,package-detail,cruise-price, and friends under/v1/admin/catalog) is live. That surface is the working, single-provider half of “package”: cards cached in the index, bookable offers compiled live by fanning out to Voyant Connect. - Today’s live callers of the generic candidate flow are higher-level AI tooling. The trips module ships MCP tools for AI-safe trip planning, revision, pricing, and reserve. The end-to-end customer-facing generic packaging surface is not a packaged operator screen yet.
candidateRefis not replay-safe. Callers persist candidates and re-resolveselectionbefore reserve. Do not book against acandidateRef.providerDatais internal. Net cost and margin live there and are never serialized into a public DTO.
Next steps
Catalog
The source-adapter contract, the live availability fan-out, and the sourced-package offer routes.
Trips
Requirements, candidates, and pinned components: how live supply composes into one envelope.
Proposals
Freezing a composed itinerary into an immutable priced proposal a customer accepts.
Flights
The dedicated flight fan-out bridged into the same ranked candidate list.