Skip to main content
Dynamic packaging answers a hard question: how do you build one itinerary out of supply you do not own, cannot pre-load, and that reprices on every request? A customer wants a hotel for these dates, a flight from this airport, and a transfer to match. None of it is a fixed dated departure sitting in your database. Each part lives upstream, behind a different provider, with its own price that is only true for the next few minutes. The answer is a live search and a composition layer. You fan a search out across every provider that can serve a need, merge what comes back into one ranked list, let an operator or an agent pick from it, and pin each pick into a trip. The composition stays resumable: candidates are persisted, re-validated, and only committed at reserve time. This page is the conceptual map. It names the primitives precisely, because the words “offer” and “package” are overloaded across the codebase, and explains what is wired today versus what a deployment still has to inject.

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 PackageOffer routes 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.
These are not the same thing. A sourced package is one provider’s offer. A composed FIT trip is many providers’ offers assembled by Voyant. Dynamic packaging is mostly about the second, but it leans on the same live-search machinery the first uses.
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 a SourceAdapter (@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 verticals do 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 flagged capability_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 AvailabilityCandidate list.
  • Never lets partial failure tank the search. Each source’s outcome lands in AvailabilityConnectionResult[], with a status of ok, partial, empty, unsupported, timeout, error, capability_missing, or vertical_skipped.
Owned inventory is a first-class search source, not a faked external provider. An 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:
  • tripRequirements is an unresolved need: a vertical plus criteria plus a criteriaVersion. Its status walks open to sourcing to candidates_ready to selected, or to no_availability when a search returns nothing.
  • tripCandidates are persisted, ranked AvailabilityCandidate rows under a requirement, each with a TTL. Status is ranked, selected, expired, or discarded.
  • tripComponents are the independently-committed parts of the envelope. A pinned component carries its entityModule, sourceConnectionId, and the candidate’s selection and candidateRef in metadata.
The flow lives in service-requirements.ts:
  1. addRequirement records the unresolved need on the envelope.
  2. sourceRequirementCandidates(db, input, deps) runs the fan-out and persists the ranked candidates. The fan-out is injected through deps.search. Trips never imports a catalog adapter; the deployment wires the search and hands it in, keeping trips deterministic and provider-agnostic.
  3. selectCandidate pins the chosen candidate into a draft catalog_booking component. It enforces selected-uniqueness: picking one demotes any previously-selected candidate for that requirement.
  4. reshopRequirement and reshopTrip clear a prior selection and re-source candidates when prices or availability go stale.
  5. expireStaleTripCandidates is a TTL reaper, intended to be driven by a deployment cron, that marks elapsed ranked candidates expired.
The draft component a candidate pins into is intentionally unpriced. 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/requirements and GET /:envelopeId/requirements
  • POST /requirements/:requirementId/candidates (source candidates)
  • POST /requirements/:requirementId/select
  • POST /requirements/:requirementId/reshop
  • POST /: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, both reshop paths) resolve sourceCandidatesDeps, 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 PackageOffer routes are wired. The operator application mounts createCatalogOffersAdminRoutes (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.
  • candidateRef is not replay-safe. Callers persist candidates and re-resolve selection before reserve. Do not book against a candidateRef.
  • providerData is 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.