From e802797369093d1070a60f83e6a31b7e20dcb3fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:02:53 +0000 Subject: [PATCH 1/4] =?UTF-8?q?docs(kb):=20GDS/NDC=20per-carrier=C3=97vend?= =?UTF-8?q?or=C3=97transaction=20capability=20matrix=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authoritative KB matrix for Agent 3.1 so routing is keyed by (carrier, vendor, transaction), not a single airline→channel map. - Res 787 framed as Shop/Order/Change process standard (not parity) - Sabre Offer/Order REST surface cited from public NDC guide - Anonymized rows from adapter docs; shop≠servicing for Duffel + TripPro - Matrix loader + router consumption; no invented NDC 21.3 defaults Co-authored-by: telivity-otaip --- CLAUDE.md | 4 +- docs/agents/stage-3-booking.md | 11 +- .../gds-ndc-capability-matrix.csv | 36 ++ .../gds-ndc-capability-matrix.md | 243 ++++++++++++ .../__tests__/capability-matrix.test.ts | 202 ++++++++++ .../src/gds-ndc-router/capability-matrix.ts | 298 +++++++++++++++ .../data/capability-matrix.json | 357 ++++++++++++++++++ .../gds-ndc-router/data/carrier-channels.json | 4 +- .../data/gds-ndc-capability-matrix.csv | 36 ++ .../booking/src/gds-ndc-router/index.ts | 44 +++ .../src/gds-ndc-router/router-engine.ts | 143 ++++++- .../booking/src/gds-ndc-router/schema.ts | 42 +++ .../booking/src/gds-ndc-router/types.ts | 87 ++++- packages/agents/booking/src/index.ts | 18 + 14 files changed, 1487 insertions(+), 38 deletions(-) create mode 100644 docs/knowledge-base/gds-ndc-capability-matrix.csv create mode 100644 docs/knowledge-base/gds-ndc-capability-matrix.md create mode 100644 packages/agents/booking/src/gds-ndc-router/__tests__/capability-matrix.test.ts create mode 100644 packages/agents/booking/src/gds-ndc-router/capability-matrix.ts create mode 100644 packages/agents/booking/src/gds-ndc-router/data/capability-matrix.json create mode 100644 packages/agents/booking/src/gds-ndc-router/data/gds-ndc-capability-matrix.csv diff --git a/CLAUDE.md b/CLAUDE.md index 4d412f0..88bcf90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,8 +119,8 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| -| "Airline X supports NDC, so I'll route all bookings through NDC" | STOP. Airlines have varying NDC adoption levels by transaction type and market. Some support NDC for shopping only. Some require NDC for certain fare types but GDS for others. Check KB for the carrier's NDC capability matrix. | -| "NDC version 21.3 is widely supported, I'll default to that" | STOP. NDC versions are NOT backward compatible in all cases. Carriers implement specific versions with carrier-specific extensions. Check KB for which version the specific carrier supports. | +| "Airline X supports NDC, so I'll route all bookings through NDC" | STOP. Airlines have varying NDC adoption levels by transaction type and vendor. Some support NDC for shopping only. Some require NDC for certain fare types but GDS for others. Check `docs/knowledge-base/gds-ndc-capability-matrix.md` for the carrier×vendor×transaction matrix. Res 787 is the Offer/Order process standard — not a channel parity checklist. | +| "NDC version 21.3 is widely supported, I'll default to that" | STOP. NDC versions are NOT backward compatible in all cases. Carriers implement specific versions with carrier-specific extensions. Check the matrix `ndc_version_notes` for the specific carrier×vendor. Never invent a version. | | "Codeshare flights route through the marketing carrier's channel" | STOP. Codeshare routing depends on ticketing arrangement, inventory control (free-sale vs blocked space), and plating carrier. Some require dual-channel booking. Check KB for the codeshare agreement's booking rules. | | "I'll map each airline to either GDS or NDC as their primary channel" | STOP. Most NDC airlines still require GDS for specific scenarios (groups, tours, corporate fares, post-booking servicing). The routing decision must be PER-TRANSACTION, not per-airline. Check KB for channel capability per transaction type. | diff --git a/docs/agents/stage-3-booking.md b/docs/agents/stage-3-booking.md index 883e9b1..13ab001 100644 --- a/docs/agents/stage-3-booking.md +++ b/docs/agents/stage-3-booking.md @@ -12,19 +12,24 @@ Distribution channel routing, PNR construction, validation, queue management, AP **Class:** `GdsNdcRouter` **Status:** Implemented -Routes booking requests to the correct distribution channel (GDS, NDC, or direct API) based on carrier config, codeshare rules, and NDC capability. Per-segment routing with fallback support. +Routes booking requests to the correct distribution channel (GDS, NDC, or direct API). **Lookup key is `(carrier, vendor, transaction)`** — never a single airline→channel map. Authoritative matrix: [`docs/knowledge-base/gds-ndc-capability-matrix.md`](../knowledge-base/gds-ndc-capability-matrix.md). Res 787 names Shop / Order / Change processes; it is not a channel parity checklist. NDC schema versions are never invented (no default `21.3`). **Input (`GdsNdcRouterInput`):** - `segments` -- routing segments (marketing/operating carrier, origin, destination, flight number) +- `transaction_type` -- `'shopping' | 'booking' | 'ticketing' | 'servicing' | 'group' | 'corporate'` (aliases for matrix labels Shop / OrderCreate / Servicing|OrderChange|OrderCancel / Groups / Corporate) +- `vendor?` -- `'sabre' | 'amadeus' | 'duffel' | 'navitaire' | 'trippro' | 'airline_direct' | 'unknown'` (required for unambiguous matrix lookup) +- `plating_carrier?` -- when plating ≠ marketing/operating (DOMAIN_QUESTION on precedence) +- `capability_matrix?` -- inline KB CSV-shaped rows; preferred over airline→channel maps +- `capability_overrides?` -- per-carrier, per-transaction escape hatch when matrix cell is unknown - `preferred_channel?` -- `'GDS' | 'NDC' | 'DIRECT'` - `preferred_gds?` -- `'AMADEUS' | 'SABRE' | 'TRAVELPORT'` - `include_fallbacks` -- whether to include fallback channels **Output (`GdsNdcRouterOutput`):** -- `routings` -- per-segment channel routing (primary channel, GDS system, NDC version, fallbacks, booking format) +- `routings` -- per-segment channel routing (primary channel, GDS system, NDC version, fallbacks, booking format); `domain_input_required` when matrix/override missing - `unified_channel` -- whether all segments can use the same channel - `recommended_channel` -- best channel for entire itinerary -- `gds_format` / `ndc_format` -- format translation stubs +- `gds_format` / `ndc_format` -- format translation stubs (`ndc_format` omitted when version unknown — never invents `21.3`) --- diff --git a/docs/knowledge-base/gds-ndc-capability-matrix.csv b/docs/knowledge-base/gds-ndc-capability-matrix.csv new file mode 100644 index 0000000..e495a08 --- /dev/null +++ b/docs/knowledge-base/gds-ndc-capability-matrix.csv @@ -0,0 +1,36 @@ +carrier,vendor,transaction,channel,ndc_version_notes,fallback,source,confidence +ANON-NDC-AGG,duffel,Shop,NDC,unknown (Duffel API version header != IATA NDC schema),GDS,docs/adapters/duffel.md,adapter_doc +ANON-NDC-AGG,duffel,OrderCreate,NDC,unknown,GDS,docs/adapters/duffel.md,adapter_doc +ANON-NDC-AGG,duffel,OrderChange,unknown,unknown,GDS,docs/adapters/duffel.md (exchange not implemented),adapter_doc +ANON-NDC-AGG,duffel,OrderCancel,unknown,unknown,GDS,docs/adapters/duffel.md + docs/architecture/ADAPTER_STATUS.md (flight cancel not implemented),adapter_doc +ANON-NDC-AGG,duffel,Servicing,GDS,n/a,none,docs/adapters/duffel.md (shop!=servicing: post-booking not on Duffel adapter path),adapter_doc +ANON-NDC-AGG,duffel,Groups,unknown,unknown,GDS,no public evidence,unknown +ANON-NDC-AGG,duffel,Corporate,unknown,unknown,GDS,no public evidence,unknown +ANON-SABRE-OO,sabre,Shop,Either,unknown (BFM may return NDC + ATPCO; schema per airline unknown),GDS,https://developer.sabre.com/guide/ndc/ndc.html + docs/adapters/sabre.md,vendor_public +ANON-SABRE-OO,sabre,OrderCreate,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/create),vendor_public +ANON-SABRE-OO,sabre,OrderChange,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/change),vendor_public +ANON-SABRE-OO,sabre,OrderCancel,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/cancel),vendor_public +ANON-SABRE-OO,sabre,Servicing,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (view/reshop/sync),vendor_public +ANON-SABRE-OO,sabre,Groups,unknown,unknown,GDS,no public evidence — often still classic GDS,unknown +ANON-SABRE-OO,sabre,Corporate,unknown,unknown,GDS,no public evidence,unknown +ANON-GDS-SS,amadeus,Shop,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,OrderCreate,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,OrderChange,unknown,n/a,none,docs/adapters/amadeus.md (exchange not in Self-Service),adapter_doc +ANON-GDS-SS,amadeus,OrderCancel,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,Servicing,unknown,n/a,none,docs/adapters/amadeus.md (refund/exchange not in Self-Service),adapter_doc +ANON-GDS-SS,amadeus,Groups,unknown,n/a,none,no public evidence,unknown +ANON-GDS-SS,amadeus,Corporate,unknown,n/a,none,no public evidence,unknown +ANON-LCC-DIRECT,navitaire,Shop,Direct/API,n/a (AIDM order model not classic NDC XML version),none,docs/adapters/navitaire.md + docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderCreate,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderChange,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderCancel,Direct/API,n/a,none,docs/adapters/navitaire.md + oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,Servicing,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,Groups,unknown,n/a,none,no public evidence,unknown +ANON-LCC-DIRECT,navitaire,Corporate,unknown,n/a,none,no public evidence,unknown +ANON-AGG-DUAL,trippro,Shop,Either,unknown (upstream may be GDS or NDC),GDS,docs/adapters/trippro.md,adapter_doc +ANON-AGG-DUAL,trippro,OrderCreate,Either,unknown,GDS,docs/adapters/trippro.md,adapter_doc +ANON-AGG-DUAL,trippro,OrderChange,unknown,unknown,GDS,docs/adapters/trippro.md (exchange not implemented),adapter_doc +ANON-AGG-DUAL,trippro,OrderCancel,GDS,n/a,none,docs/adapters/trippro.md (SOAP CancelPNR),adapter_doc +ANON-AGG-DUAL,trippro,Servicing,GDS,n/a,none,docs/adapters/trippro.md (SOAP ReadPNR/OrderTicket; shop!=servicing),adapter_doc +ANON-AGG-DUAL,trippro,Groups,unknown,unknown,GDS,no public evidence,unknown +ANON-AGG-DUAL,trippro,Corporate,unknown,unknown,GDS,no public evidence,unknown diff --git a/docs/knowledge-base/gds-ndc-capability-matrix.md b/docs/knowledge-base/gds-ndc-capability-matrix.md new file mode 100644 index 0000000..c05d841 --- /dev/null +++ b/docs/knowledge-base/gds-ndc-capability-matrix.md @@ -0,0 +1,243 @@ +# GDS / NDC channel capability matrix + +**Authoritative for Agent 3.1 (GDS/NDC Router).** +Issue: [#142](https://github.com/TelivityAI/otaip/issues/142). +Companion CSV: [`gds-ndc-capability-matrix.csv`](./gds-ndc-capability-matrix.csv). + +--- + +## Rule (non-negotiable) + +**Routing is per `(carrier, vendor, transaction)`, never a single airline → channel map.** + +“Airline X supports NDC” does **not** mean every transaction routes to NDC. The same carrier may shop on NDC, book on NDC, and still require GDS (or Direct/API) for groups, corporate/private fares, or post-booking servicing — and that split can differ by **vendor** (Sabre Offer/Order vs Amadeus Self-Service vs Duffel vs airline direct). + +Do **not** invent NDC schema versions. Do **not** default every carrier to `21.3`. If the version/profile is not evidenced for that carrier×vendor, write `unknown`. + +--- + +## What Res 787 is (and is not) + +[IATA Resolution 787 — Enhanced Airline Distribution](https://www.iata.org/contentassets/6de4dce5f38b45ce82b0db42acd23d1c/ndc-resolution-787.pdf) is the **industry process standard** for Offer/Order distribution. It defines the business dialogues: + +| Res 787 section | Process | Matrix transaction labels | +| --- | --- | --- | +| §3.1.1 | Authenticate & Shop → product offer | `Shop` | +| §3.1.2 | Order (commit / pay / fulfill / document) | `OrderCreate` | +| §3.1.3 | Change (modify, add, cancel, refund) | `OrderChange`, `OrderCancel`, `Servicing` | + +**Res 787 is not a carrier×channel parity matrix.** It does not say which airline uses GDS vs NDC for which transaction. That operational record is what *this* matrix holds. Schema versions (17.2 / 18.1 / 21.3 / 24.1 / vendor profiles) are implementation choices per carrier×vendor — never a global default. + +Extended labels used by OTAs/TMCs (not named as separate Res 787 processes, but common channel forks): + +| Label | Meaning | +| --- | --- | +| `Groups` | Group PNR / group inventory / group contracts | +| `Corporate` | Corporate / private / negotiated fares | + +--- + +## Columns + +| Column | Values | +| --- | --- | +| `carrier` | Anonymized id (`ANON-*`) or real IATA when publicly evidenced. Prefer anonymized in this seed. | +| `vendor` | `sabre` \| `amadeus` \| `duffel` \| `navitaire` \| `trippro` \| `airline_direct` \| `unknown` | +| `transaction` | `Shop` \| `OrderCreate` \| `OrderChange` \| `OrderCancel` \| `Servicing` \| `Groups` \| `Corporate` | +| `channel` | `NDC` \| `GDS` \| `Direct/API` \| `Either` \| `unknown` | +| `ndc_version_notes` | Carrier×vendor schema/profile note, or `unknown`. Never invent. | +| `fallback` | Next channel if primary unavailable, or `none` / `unknown` | +| `source` | Public doc path or URL that justifies the cell | +| `confidence` | `adapter_doc` \| `vendor_public` \| `unknown` | + +Machine-readable form: same columns in [`gds-ndc-capability-matrix.csv`](./gds-ndc-capability-matrix.csv). + +--- + +## Sabre Offer/Order REST surface (vendor reference) + +Public Sabre NDC / Offer & Order guide: +https://developer.sabre.com/guide/ndc/ndc.html + +Sabre documents an NDC-enabled **REST/JSON Offer and Order** API set (not a claim that every Sabre-hosted carrier supports every call). Current-release APIs listed on that guide: + +| API | Endpoint | Maps to matrix transaction | +| --- | --- | --- | +| Bargain Finder Max (shop, may return NDC offers) | `v5/offers/shop` | `Shop` | +| Offer Price | `v1/offers/price` | `Shop` (price confirmation) | +| Order Create | `v1/orders/create` | `OrderCreate` | +| Order Cancel (pre-fulfillment) | `v1/orders/cancel` | `OrderCancel` | +| Order Reprice | `v1/offers/repriceOrder` | `Servicing` | +| Order Change (fulfill / mixed-content cancel) | `v1/orders/change` | `OrderChange` / `Servicing` | +| Order View | `v1/orders/view` | `Servicing` | +| Offer Cancel Reshop | `v1/offers/reshop/cancelOrder` | `OrderCancel` / `Servicing` | +| Order Cancel Void & Refund | `v1/orders/cancel` | `OrderCancel` / `Servicing` | +| Offer Reshop Shop | `v1/offers/reshop/shop` | `Servicing` | +| Order Change Exchange | `v1/orders/change/exchange` | `OrderChange` / `Servicing` | +| Order Sync | `v1/orders/sync` | `Servicing` | + +**Implication for routing:** even on Sabre, shop vs fulfill vs exchange are **different API calls**. Carrier participation in each call is not implied by “content is NDC on Sabre.” Per-carrier enablement remains `unknown` until evidenced — do not copy this vendor API list into every carrier row as if it were carrier capability. + +Sabre also documents that classic ticketing cryptic/LLS commands are **not** valid against NDC air segments created via Offer/Order — another reason channel choice must be per transaction, not per airline. + +--- + +## Seed matrix (public / anonymized) + +Sources limited to this repo’s adapter docs + the Sabre public guide above. Cells without evidence are `unknown`. + +### ANON-NDC-AGG (Duffel path) — Shop ≠ Servicing + +Participating airline inventory reached through Duffel’s NDC aggregator. Adapter implements search / price / book; flight cancel and exchange are **not** implemented in the adapter (`docs/adapters/duffel.md`, `docs/architecture/ADAPTER_STATUS.md`). + +| transaction | channel | ndc_version_notes | fallback | source | +| --- | --- | --- | --- | --- | +| Shop | NDC | unknown (Duffel API version header ≠ IATA NDC schema version) | GDS | `docs/adapters/duffel.md` | +| OrderCreate | NDC | unknown | GDS | `docs/adapters/duffel.md` | +| OrderChange | unknown | unknown | GDS | adapter: exchange not implemented | +| OrderCancel | unknown | unknown | GDS | adapter: flight cancel not implemented | +| Servicing | GDS | n/a | none | shop≠servicing: post-booking not on Duffel adapter path | +| Groups | unknown | unknown | GDS | no public evidence | +| Corporate | unknown | unknown | GDS | no public evidence | + +### ANON-SABRE-OO (Sabre Offer/Order path) + +Carrier distributing NDC content via Sabre Offer/Order REST. Vendor API surface exists (table above); **per-carrier** enablement of each endpoint is `unknown` unless separately contracted. + +| transaction | channel | ndc_version_notes | fallback | source | +| --- | --- | --- | --- | --- | +| Shop | Either | unknown (BFM may return NDC + ATPCO; schema per airline unknown) | GDS | Sabre NDC guide + `docs/adapters/sabre.md` | +| OrderCreate | NDC | unknown | GDS | Sabre `v1/orders/create` | +| OrderChange | NDC | unknown | GDS | Sabre `v1/orders/change` (+ exchange) | +| OrderCancel | NDC | unknown | GDS | Sabre `v1/orders/cancel` | +| Servicing | NDC | unknown | GDS | Order View / Reshop / Sync | +| Groups | unknown | unknown | GDS | no public evidence — often still classic GDS | +| Corporate | unknown | unknown | GDS | no public evidence | + +### ANON-GDS-SS (Amadeus Self-Service path) + +Full GDS Self-Service shopping/booking/cancel; ticketing / exchange / refund **not** available on Self-Service tier (`docs/adapters/amadeus.md`). + +| transaction | channel | ndc_version_notes | fallback | source | +| --- | --- | --- | --- | --- | +| Shop | GDS | n/a | none | `docs/adapters/amadeus.md` | +| OrderCreate | GDS | n/a | none | Flight Orders create | +| OrderChange | unknown | n/a | none | exchange not in Self-Service | +| OrderCancel | GDS | n/a | none | Flight Orders delete | +| Servicing | unknown | n/a | none | refund/exchange not in Self-Service | +| Groups | unknown | n/a | none | no public evidence | +| Corporate | unknown | n/a | none | no public evidence | + +### ANON-LCC-DIRECT (Navitaire / OOSD path) + +Direct/API (LCC platform). Order model via AIDM 24.1-style OrderOperations when `supportsOrders` (`docs/adapters/navitaire.md`, `docs/adapters/oosd-navitaire.md`). + +| transaction | channel | ndc_version_notes | fallback | source | +| --- | --- | --- | --- | --- | +| Shop | Direct/API | n/a (AIDM order model, not classic NDC XML version) | none | navitaire + oosd docs | +| OrderCreate | Direct/API | n/a | none | `orderCreate` / commit flow | +| OrderChange | Direct/API | n/a | none | `orderChange` declared | +| OrderCancel | Direct/API | n/a | none | `orderCancel` / cancel flow | +| Servicing | Direct/API | n/a | none | retrieve + events | +| Groups | unknown | n/a | none | no public evidence | +| Corporate | unknown | n/a | none | no public evidence | + +### ANON-AGG-DUAL (TripPro dual-host) — Shop ≠ Servicing + +Aggregator: REST for search/price/book; SOAP PNR path for retrieve / ticket / cancel (`docs/adapters/trippro.md`). + +| transaction | channel | ndc_version_notes | fallback | source | +| --- | --- | --- | --- | --- | +| Shop | Either | unknown (upstream may be GDS or NDC) | GDS | trippro REST search | +| OrderCreate | Either | unknown | GDS | trippro REST book | +| OrderChange | unknown | unknown | GDS | exchange not implemented | +| OrderCancel | GDS | n/a | none | SOAP CancelPNR | +| Servicing | GDS | n/a | none | SOAP ReadPNR / OrderTicket — **shop≠servicing** | +| Groups | unknown | unknown | GDS | no public evidence | +| Corporate | unknown | unknown | GDS | no public evidence | + +--- + +## When GDS remains mandatory (even for “NDC airlines”) + +From CLAUDE.md Agent 3.1 guards + adapter limits — treat as routing heuristics that still require a matrix row (do not hard-code as universal law): + +1. **Groups** — often still classic GDS / special desks. +2. **Corporate / private fares** — frequently GDS-filed or agency-desk only. +3. **Post-booking servicing** — cancel / exchange / void / refund may be GDS or a different NDC profile than shop. +4. **Mixed content PNRs** — Sabre documents Order Change specifically for canceling NDC when other air content exists in the PNR. +5. **Adapter gaps** — vendor marketing “NDC” while this repo’s adapter only implements Shop/OrderCreate (Duffel) forces a GDS (or unknown) servicing path until the adapter/API is proven. + +--- + +## Codeshare / plating + +Res 787 §3.1.2.4 requires interline reservation/ticketing data exchange when space is confirmed — it does **not** say whether marketing or operating channel wins for routing. + +OTAIP Agent 3.1 today (`carrier-channels.json` + `router-engine.ts`): + +| Rule | Behavior | +| --- | --- | +| Default | Prefer **operating** carrier’s matrix/config when `operating_carrier ≠ marketing_carrier` and operating has a row | +| Fallback | **Marketing** carrier when operating has no matrix/config | +| Plating | **Not in input today** | + +### Proposed plating notes (for Agent 3.1 consumers) + +| Situation | Who wins for channel lookup | +| --- | --- | +| Simple codeshare, free-sale, same plating as marketing | Operating carrier channel for inventory; marketing for offer display identity | +| Blocked-space / hard block | // TODO: DOMAIN_QUESTION: blocked-space codeshare — marketing vs operating channel? | +| Plating carrier ≠ marketing and ≠ operating | Lookup by **`plating_carrier`** when provided; do not invent | +| Dual-channel required | Return non-unified routing; do not collapse to one airline→channel | + +--- + +## Proposed Agent 3.1 input fields (matrix consumption) + +These fields let 3.1 consume the matrix **without** a single airline→channel map. Existing engine fields are noted. + +| Field | Required | Maps to / status | +| --- | --- | --- | +| `transaction_type` | yes | Prefer matrix labels `Shop` \| `OrderCreate` \| `OrderChange` \| `OrderCancel` \| `Servicing` \| `Groups` \| `Corporate`. Today’s engine enum aliases: `shopping`←Shop, `booking`←OrderCreate, `servicing`←OrderChange/OrderCancel/Servicing, `group`←Groups, `corporate`←Corporate, plus `ticketing` for document issuance | +| `segments[].marketing_carrier` | yes | Matrix `carrier` key (after resolving anonymized→IATA in deployment data) | +| `segments[].operating_carrier` | no | Codeshare operating-first lookup | +| `plating_carrier` | no (**proposed**) | When set, matrix lookup uses plating for channel; // TODO: DOMAIN_QUESTION until plating rules confirmed | +| `vendor` | no (**proposed**, strongly recommended) | Matrix `vendor` dimension — without it, multiple vendor rows for one carrier are ambiguous | +| `preferred_channel` | no | Existing override; only applied if channel is in the matrix row’s allowed set | +| `preferred_gds` | no | Existing; only meaningful when channel is `GDS` | +| `include_fallbacks` | yes | Existing; use matrix `fallback` column | +| `capability_matrix` | no (**proposed**) | Inline rows (CSV-shaped) for the request — preferred over baked airline maps | +| `capability_overrides` | no | Existing escape hatch: `Record>>` | + +### Lookup algorithm (normative for consumers) + +1. Resolve routing carrier (operating → marketing; plating if `plating_carrier` set — see DOMAIN_QUESTION). +2. Require `transaction_type` (Res 787 / matrix label or engine alias). +3. Look up `(carrier, vendor, transaction)` in the matrix. +4. If `channel` is `unknown` → `domain_input_required` (do not guess). +5. If `channel` is known → build primary + fallback; set `ndc_version` **only** when `ndc_version_notes` is a concrete schema id, else `null`. +6. Never apply a carrier’s Shop channel to Servicing/Groups/Corporate without a separate row. + +Reference implementation: `packages/agents/booking/src/gds-ndc-router/capability-matrix.ts`. + +--- + +## Open DOMAIN_QUESTIONS + +1. Per-carrier enablement of each Sabre Offer/Order endpoint (guide lists vendor APIs, not airline participation). +2. Plating-carrier precedence vs marketing/operating for channel selection. +3. Whether `Groups` / `Corporate` should remain separate transactions or become fare-attribute filters on Shop/OrderCreate. +4. How Duffel `Duffel-Version` maps (if at all) to IATA NDC schema versions for a given airline. +5. Ingestion path for production matrix rows (CSV in KB vs registry service) — seed only here. + +--- + +## Anti-patterns (CLAUDE.md Agent 3.1) + +| Do not | Do | +| --- | --- | +| `carrier → NDC` for all transactions | `(carrier, vendor, transaction) → channel` | +| Default NDC version to `21.3` | Leave `unknown` / `null` until evidenced | +| Treat Res 787 as a parity checklist | Use Res 787 for process names; matrix for channel facts | +| Parse GDS fare displays as capability | Use structured matrix + vendor public API lists | diff --git a/packages/agents/booking/src/gds-ndc-router/__tests__/capability-matrix.test.ts b/packages/agents/booking/src/gds-ndc-router/__tests__/capability-matrix.test.ts new file mode 100644 index 0000000..0e09612 --- /dev/null +++ b/packages/agents/booking/src/gds-ndc-router/__tests__/capability-matrix.test.ts @@ -0,0 +1,202 @@ +/** + * Capability matrix helpers — Agent 3.1 (#142). + * + * Asserts per-(carrier, vendor, transaction) consumption without a + * single airline→channel map, and that NDC versions are never invented. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { GdsNdcRouter } from '../index.js'; +import { + getSeedCapabilityMatrix, + parseCapabilityMatrixCsv, + lookupMatrixRow, + matrixRowToCarrierConfig, + buildCapabilityOverridesFromMatrix, + parseNdcVersionNotes, + MATRIX_TO_AGENT_TRANSACTION, +} from '../capability-matrix.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const kbCsvPath = join( + __dirname, + '../../../../../../docs/knowledge-base/gds-ndc-capability-matrix.csv', +); + +describe('capability matrix (KB #142)', () => { + it('seed matrix has rows for every (carrier, vendor, transaction) seed', () => { + const rows = getSeedCapabilityMatrix(); + expect(rows.length).toBeGreaterThanOrEqual(35); + const carriers = new Set(rows.map((r) => r.carrier)); + expect(carriers.has('ANON-NDC-AGG')).toBe(true); + expect(carriers.has('ANON-AGG-DUAL')).toBe(true); + }); + + it('KB CSV parses and matches seed carrier set', () => { + const csv = readFileSync(kbCsvPath, 'utf8'); + const parsed = parseCapabilityMatrixCsv(csv); + expect(parsed.length).toBe(getSeedCapabilityMatrix().length); + }); + + it('documents shop ≠ servicing for Duffel and TripPro anonymized carriers', () => { + const rows = getSeedCapabilityMatrix(); + const duffelShop = lookupMatrixRow(rows, { + carrier: 'ANON-NDC-AGG', + vendor: 'duffel', + transaction: 'Shop', + }); + const duffelSvc = lookupMatrixRow(rows, { + carrier: 'ANON-NDC-AGG', + vendor: 'duffel', + transaction: 'Servicing', + }); + expect(duffelShop?.channel).toBe('NDC'); + expect(duffelSvc?.channel).toBe('GDS'); + expect(duffelShop?.channel).not.toBe(duffelSvc?.channel); + + const tripShop = lookupMatrixRow(rows, { + carrier: 'ANON-AGG-DUAL', + vendor: 'trippro', + transaction: 'Shop', + }); + const tripSvc = lookupMatrixRow(rows, { + carrier: 'ANON-AGG-DUAL', + vendor: 'trippro', + transaction: 'Servicing', + }); + expect(tripShop?.channel).toBe('Either'); + expect(tripSvc?.channel).toBe('GDS'); + }); + + it('never invents an NDC version from unknown notes', () => { + expect(parseNdcVersionNotes('unknown')).toBeNull(); + expect(parseNdcVersionNotes('unknown (Duffel API version header)')).toBeNull(); + expect(parseNdcVersionNotes('n/a')).toBeNull(); + expect(parseNdcVersionNotes('schema 18.1 per portal')).toBe('18.1'); + }); + + it('maps Res 787 matrix labels to agent transaction types', () => { + expect(MATRIX_TO_AGENT_TRANSACTION.Shop).toBe('shopping'); + expect(MATRIX_TO_AGENT_TRANSACTION.OrderCreate).toBe('booking'); + expect(MATRIX_TO_AGENT_TRANSACTION.Groups).toBe('group'); + }); + + it('matrixRowToCarrierConfig skips unknown and unresolved Either', () => { + const rows = getSeedCapabilityMatrix(); + const unknown = lookupMatrixRow(rows, { + carrier: 'ANON-NDC-AGG', + vendor: 'duffel', + transaction: 'OrderChange', + })!; + expect(matrixRowToCarrierConfig(unknown)).toBeUndefined(); + + const either = lookupMatrixRow(rows, { + carrier: 'ANON-SABRE-OO', + vendor: 'sabre', + transaction: 'Shop', + })!; + expect(matrixRowToCarrierConfig(either)).toBeUndefined(); + expect(matrixRowToCarrierConfig(either, 'NDC')?.channel_priority[0]).toBe('NDC'); + }); + + it('buildCapabilityOverridesFromMatrix does not create a single airline→channel map', () => { + const rows = getSeedCapabilityMatrix(); + const overrides = buildCapabilityOverridesFromMatrix(rows, 'ANON-NDC-AGG', 'duffel'); + // Shopping/booking NDC, servicing GDS — different channels per transaction. + expect(overrides.shopping?.channel_priority[0]).toBe('NDC'); + expect(overrides.booking?.channel_priority[0]).toBe('NDC'); + expect(overrides.servicing?.channel_priority[0]).toBe('GDS'); + expect(overrides.shopping?.ndc_version).toBeNull(); + }); +}); + +describe('GdsNdcRouter matrix consumption', () => { + const agent = new GdsNdcRouter(); + + beforeAll(async () => { + await agent.initialize(); + }); + + it('routes ANON-NDC-AGG Shop via NDC and Servicing via GDS from matrix', async () => { + const matrix = getSeedCapabilityMatrix().map((r) => ({ + carrier: r.carrier, + vendor: r.vendor, + transaction: r.transaction, + channel: r.channel, + ndc_version_notes: r.ndc_version_notes, + fallback: r.fallback, + source: r.source, + confidence: r.confidence, + })); + + const shop = await agent.execute({ + data: { + segments: [{ marketing_carrier: 'AN', origin: 'LHR', destination: 'JFK' }], + transaction_type: 'shopping', + vendor: 'duffel', + // Use matrix carrier id as marketing by rewriting rows for this test carrier. + capability_matrix: matrix.map((r) => + r.carrier === 'ANON-NDC-AGG' ? { ...r, carrier: 'AN' } : r, + ), + include_fallbacks: true, + }, + }); + expect(shop.data.routings[0]!.domain_input_required).toBeUndefined(); + expect(shop.data.routings[0]!.primary_channel).toBe('NDC'); + expect(shop.data.routings[0]!.ndc_version).toBeNull(); + expect(shop.data.ndc_format).toBeNull(); // no invented 21.3 + + const svc = await agent.execute({ + data: { + segments: [{ marketing_carrier: 'AN', origin: 'LHR', destination: 'JFK' }], + transaction_type: 'servicing', + vendor: 'duffel', + capability_matrix: matrix.map((r) => + r.carrier === 'ANON-NDC-AGG' ? { ...r, carrier: 'AN' } : r, + ), + include_fallbacks: false, + }, + }); + expect(svc.data.routings[0]!.primary_channel).toBe('GDS'); + }); + + it('returns DOMAIN_INPUT_REQUIRED for Sabre Either Shop without preferred_channel', async () => { + const matrix = getSeedCapabilityMatrix() + .filter((r) => r.carrier === 'ANON-SABRE-OO') + .map((r) => ({ ...r, carrier: 'SR' })); + + const result = await agent.execute({ + data: { + segments: [{ marketing_carrier: 'SR', origin: 'DFW', destination: 'LHR' }], + transaction_type: 'shopping', + vendor: 'sabre', + capability_matrix: matrix, + include_fallbacks: false, + }, + }); + expect(result.data.routings[0]!.domain_input_required).toBe(true); + }); + + it('resolves Sabre Either Shop when preferred_channel is supplied', async () => { + const matrix = getSeedCapabilityMatrix() + .filter((r) => r.carrier === 'ANON-SABRE-OO') + .map((r) => ({ ...r, carrier: 'SR' })); + + const result = await agent.execute({ + data: { + segments: [{ marketing_carrier: 'SR', origin: 'DFW', destination: 'LHR' }], + transaction_type: 'shopping', + vendor: 'sabre', + preferred_channel: 'NDC', + capability_matrix: matrix, + include_fallbacks: true, + }, + }); + expect(result.data.routings[0]!.domain_input_required).toBeUndefined(); + expect(result.data.routings[0]!.primary_channel).toBe('NDC'); + expect(result.data.routings[0]!.ndc_version).toBeNull(); + }); +}); diff --git a/packages/agents/booking/src/gds-ndc-router/capability-matrix.ts b/packages/agents/booking/src/gds-ndc-router/capability-matrix.ts new file mode 100644 index 0000000..dd7193d --- /dev/null +++ b/packages/agents/booking/src/gds-ndc-router/capability-matrix.ts @@ -0,0 +1,298 @@ +/** + * GDS/NDC capability matrix — consumption helpers for Agent 3.1. + * + * Authoritative narrative: docs/knowledge-base/gds-ndc-capability-matrix.md + * Seed data: ./data/capability-matrix.json (from the KB CSV). + * + * Routing key is (carrier, vendor, transaction) — never a single + * airline → channel map. Res 787 defines Shop / Order / Change process + * names; it is NOT a channel parity checklist. NDC schema versions are + * never invented (no "everyone is 21.3"). + */ + +import type { + CarrierChannelConfig, + DistributionChannel, + GdsSystem, + NdcVersion, + TransactionCapabilityOverrides, + TransactionType, +} from './types.js'; +import capabilityMatrixJson from './data/capability-matrix.json'; + +/** Matrix transaction labels aligned with Res 787 + OTA/TMC forks. */ +export type MatrixTransaction = + | 'Shop' + | 'OrderCreate' + | 'OrderChange' + | 'OrderCancel' + | 'Servicing' + | 'Groups' + | 'Corporate'; + +export type MatrixVendor = + | 'sabre' + | 'amadeus' + | 'duffel' + | 'navitaire' + | 'trippro' + | 'airline_direct' + | 'unknown'; + +export type MatrixChannel = 'NDC' | 'GDS' | 'Direct/API' | 'Either' | 'unknown'; + +export type MatrixConfidence = 'adapter_doc' | 'vendor_public' | 'unknown'; + +export interface CapabilityMatrixRow { + carrier: string; + vendor: MatrixVendor; + transaction: MatrixTransaction; + channel: MatrixChannel; + ndc_version_notes: string; + fallback: string; + source: string; + confidence: MatrixConfidence; +} + +export interface MatrixLookupKey { + carrier: string; + vendor: MatrixVendor; + transaction: MatrixTransaction; +} + +/** Map Res 787 / matrix labels → existing Agent 3.1 TransactionType. */ +export const MATRIX_TO_AGENT_TRANSACTION: Record = { + Shop: 'shopping', + OrderCreate: 'booking', + OrderChange: 'servicing', + OrderCancel: 'servicing', + Servicing: 'servicing', + Groups: 'group', + Corporate: 'corporate', +}; + +const AGENT_TO_MATRIX_TRANSACTIONS: Record = { + shopping: ['Shop'], + booking: ['OrderCreate'], + ticketing: ['Servicing'], + servicing: ['Servicing', 'OrderChange', 'OrderCancel'], + group: ['Groups'], + corporate: ['Corporate'], +}; + +const KNOWN_NDC_VERSIONS = new Set(['17.2', '18.1', '21.3']); + +interface MatrixFile { + rows: CapabilityMatrixRow[]; +} + +const matrixFile = capabilityMatrixJson as unknown as MatrixFile; + +/** Seed rows shipped with the agent (mirrors the KB CSV). */ +export function getSeedCapabilityMatrix(): readonly CapabilityMatrixRow[] { + return matrixFile.rows; +} + +/** + * Parse a capability-matrix CSV (header row required). + * Unknown cells stay as the literal string "unknown". + */ +export function parseCapabilityMatrixCsv(csv: string): CapabilityMatrixRow[] { + const lines = csv + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length < 2) return []; + + const header = splitCsvLine(lines[0]!); + const idx = (name: string): number => { + const i = header.indexOf(name); + if (i < 0) { + throw new Error(`capability matrix CSV missing column: ${name}`); + } + return i; + }; + const iCarrier = idx('carrier'); + const iVendor = idx('vendor'); + const iTxn = idx('transaction'); + const iChannel = idx('channel'); + const iNotes = idx('ndc_version_notes'); + const iFallback = idx('fallback'); + const iSource = idx('source'); + const iConf = idx('confidence'); + + return lines.slice(1).map((line) => { + const cols = splitCsvLine(line); + return { + carrier: cols[iCarrier] ?? '', + vendor: (cols[iVendor] ?? 'unknown') as MatrixVendor, + transaction: (cols[iTxn] ?? 'Shop') as MatrixTransaction, + channel: (cols[iChannel] ?? 'unknown') as MatrixChannel, + ndc_version_notes: cols[iNotes] ?? 'unknown', + fallback: cols[iFallback] ?? 'unknown', + source: cols[iSource] ?? '', + confidence: (cols[iConf] ?? 'unknown') as MatrixConfidence, + }; + }); +} + +function splitCsvLine(line: string): string[] { + const cols: string[] = []; + let cur = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (c === '"') { + inQuotes = !inQuotes; + continue; + } + if (c === ',' && !inQuotes) { + cols.push(cur); + cur = ''; + continue; + } + cur += c; + } + cols.push(cur); + return cols; +} + +export function lookupMatrixRow( + rows: readonly CapabilityMatrixRow[], + key: MatrixLookupKey, +): CapabilityMatrixRow | undefined { + return rows.find( + (r) => + r.carrier === key.carrier && + r.vendor === key.vendor && + r.transaction === key.transaction, + ); +} + +/** + * Resolve a matrix channel cell to a DistributionChannel. + * Returns undefined when the cell is unknown or Either without preference. + */ +export function matrixChannelToDistribution( + channel: MatrixChannel, + preferred?: DistributionChannel, +): DistributionChannel | undefined { + switch (channel) { + case 'NDC': + return 'NDC'; + case 'GDS': + return 'GDS'; + case 'Direct/API': + return 'DIRECT'; + case 'Either': + if (preferred && (preferred === 'NDC' || preferred === 'GDS' || preferred === 'DIRECT')) { + return preferred; + } + // Either without preference is not a decision — caller must choose. + return undefined; + case 'unknown': + return undefined; + } +} + +/** Extract a concrete NdcVersion from notes, or null — never invent 21.3. */ +export function parseNdcVersionNotes(notes: string): NdcVersion | null { + if (!notes || notes === 'unknown' || notes.startsWith('n/a') || notes.startsWith('unknown')) { + return null; + } + for (const v of KNOWN_NDC_VERSIONS) { + if (notes.includes(v)) return v; + } + return null; +} + +function parseFallbackChannel(fallback: string): DistributionChannel | null { + switch (fallback) { + case 'GDS': + return 'GDS'; + case 'NDC': + return 'NDC'; + case 'Direct/API': + case 'DIRECT': + return 'DIRECT'; + default: + return null; + } +} + +function defaultGdsForVendor(vendor: MatrixVendor): GdsSystem | null { + switch (vendor) { + case 'sabre': + return 'SABRE'; + case 'amadeus': + return 'AMADEUS'; + default: + return null; + } +} + +/** + * Convert one matrix row into a CarrierChannelConfig for capability_overrides. + * Returns undefined when channel is unknown / unresolved Either. + */ +export function matrixRowToCarrierConfig( + row: CapabilityMatrixRow, + preferredChannel?: DistributionChannel, +): CarrierChannelConfig | undefined { + const primary = matrixChannelToDistribution(row.channel, preferredChannel); + if (!primary) return undefined; + + const fallback = parseFallbackChannel(row.fallback); + const channels: DistributionChannel[] = [primary]; + if (fallback && fallback !== primary) channels.push(fallback); + + const channel_priority: DistributionChannel[] = [...channels]; + const ndc_capable = primary === 'NDC' || row.channel === 'Either'; + const ndc_version = ndc_capable ? parseNdcVersionNotes(row.ndc_version_notes) : null; + + return { + name: `${row.carrier}/${row.vendor}`, + channels, + channel_priority, + ndc_version, + gds_preference: primary === 'GDS' || fallback === 'GDS' ? defaultGdsForVendor(row.vendor) : null, + ndc_capable: ndc_capable && primary === 'NDC', + ndc_provider_id: primary === 'NDC' ? `NDC_${row.vendor.toUpperCase()}` : null, + }; +} + +/** + * Build capability_overrides for a carrier from matrix rows for one vendor. + * Skips unknown / unresolved Either rows (caller must DOMAIN_INPUT_REQUIRED). + */ +export function buildCapabilityOverridesFromMatrix( + rows: readonly CapabilityMatrixRow[], + carrier: string, + vendor: MatrixVendor, + preferredChannel?: DistributionChannel, +): TransactionCapabilityOverrides { + const overrides: TransactionCapabilityOverrides = {}; + const carrierRows = rows.filter((r) => r.carrier === carrier && r.vendor === vendor); + + for (const row of carrierRows) { + const config = matrixRowToCarrierConfig(row, preferredChannel); + if (!config) continue; + const agentTxn = MATRIX_TO_AGENT_TRANSACTION[row.transaction]; + // First concrete row for a given agent transaction wins (e.g. Servicing + // before OrderChange when both map to 'servicing'). + if (!overrides[agentTxn]) { + overrides[agentTxn] = config; + } + } + return overrides; +} + +/** + * Convenience: resolve agent TransactionType → preferred matrix labels + * (for callers that still speak shopping/booking/…). + */ +export function matrixTransactionsForAgentType( + type: TransactionType, +): readonly MatrixTransaction[] { + return AGENT_TO_MATRIX_TRANSACTIONS[type]; +} diff --git a/packages/agents/booking/src/gds-ndc-router/data/capability-matrix.json b/packages/agents/booking/src/gds-ndc-router/data/capability-matrix.json new file mode 100644 index 0000000..9798a15 --- /dev/null +++ b/packages/agents/booking/src/gds-ndc-router/data/capability-matrix.json @@ -0,0 +1,357 @@ +{ + "_comment": "Seed from docs/knowledge-base/gds-ndc-capability-matrix.csv — KB markdown is authoritative. Per (carrier, vendor, transaction). Res 787 is the process standard, not a parity matrix. No invented NDC versions.", + "_version": "0.1.0", + "_source": "docs/knowledge-base/gds-ndc-capability-matrix.csv", + "rows": [ + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "Shop", + "channel": "NDC", + "ndc_version_notes": "unknown (Duffel API version header != IATA NDC schema)", + "fallback": "GDS", + "source": "docs/adapters/duffel.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "OrderCreate", + "channel": "NDC", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "docs/adapters/duffel.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "OrderChange", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "docs/adapters/duffel.md (exchange not implemented)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "OrderCancel", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "docs/adapters/duffel.md + docs/architecture/ADAPTER_STATUS.md (flight cancel not implemented)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "Servicing", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/duffel.md (shop!=servicing: post-booking not on Duffel adapter path)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "Groups", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-NDC-AGG", + "vendor": "duffel", + "transaction": "Corporate", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "Shop", + "channel": "Either", + "ndc_version_notes": "unknown (BFM may return NDC + ATPCO; schema per airline unknown)", + "fallback": "GDS", + "source": "https://developer.sabre.com/guide/ndc/ndc.html + docs/adapters/sabre.md", + "confidence": "vendor_public" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "OrderCreate", + "channel": "NDC", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/create)", + "confidence": "vendor_public" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "OrderChange", + "channel": "NDC", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/change)", + "confidence": "vendor_public" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "OrderCancel", + "channel": "NDC", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/cancel)", + "confidence": "vendor_public" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "Servicing", + "channel": "NDC", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "https://developer.sabre.com/guide/ndc/ndc.html (view/reshop/sync)", + "confidence": "vendor_public" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "Groups", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence — often still classic GDS", + "confidence": "unknown" + }, + { + "carrier": "ANON-SABRE-OO", + "vendor": "sabre", + "transaction": "Corporate", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "Shop", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/amadeus.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "OrderCreate", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/amadeus.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "OrderChange", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/amadeus.md (exchange not in Self-Service)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "OrderCancel", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/amadeus.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "Servicing", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/amadeus.md (refund/exchange not in Self-Service)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "Groups", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-GDS-SS", + "vendor": "amadeus", + "transaction": "Corporate", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "Shop", + "channel": "Direct/API", + "ndc_version_notes": "n/a (AIDM order model not classic NDC XML version)", + "fallback": "none", + "source": "docs/adapters/navitaire.md + docs/adapters/oosd-navitaire.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "OrderCreate", + "channel": "Direct/API", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/oosd-navitaire.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "OrderChange", + "channel": "Direct/API", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/oosd-navitaire.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "OrderCancel", + "channel": "Direct/API", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/navitaire.md + oosd-navitaire.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "Servicing", + "channel": "Direct/API", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/oosd-navitaire.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "Groups", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-LCC-DIRECT", + "vendor": "navitaire", + "transaction": "Corporate", + "channel": "unknown", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "Shop", + "channel": "Either", + "ndc_version_notes": "unknown (upstream may be GDS or NDC)", + "fallback": "GDS", + "source": "docs/adapters/trippro.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "OrderCreate", + "channel": "Either", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "docs/adapters/trippro.md", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "OrderChange", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "docs/adapters/trippro.md (exchange not implemented)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "OrderCancel", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/trippro.md (SOAP CancelPNR)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "Servicing", + "channel": "GDS", + "ndc_version_notes": "n/a", + "fallback": "none", + "source": "docs/adapters/trippro.md (SOAP ReadPNR/OrderTicket; shop!=servicing)", + "confidence": "adapter_doc" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "Groups", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence", + "confidence": "unknown" + }, + { + "carrier": "ANON-AGG-DUAL", + "vendor": "trippro", + "transaction": "Corporate", + "channel": "unknown", + "ndc_version_notes": "unknown", + "fallback": "GDS", + "source": "no public evidence", + "confidence": "unknown" + } + ] +} diff --git a/packages/agents/booking/src/gds-ndc-router/data/carrier-channels.json b/packages/agents/booking/src/gds-ndc-router/data/carrier-channels.json index 574f014..49fb508 100644 --- a/packages/agents/booking/src/gds-ndc-router/data/carrier-channels.json +++ b/packages/agents/booking/src/gds-ndc-router/data/carrier-channels.json @@ -1,6 +1,6 @@ { - "_comment": "Airline-to-distribution-channel mapping for top 30 airlines. channel_priority is ordered preference.", - "_version": "0.1.0", + "_comment": "SHOPPING/BOOKING HINTS ONLY — not a per-airline channel map for all transactions. Authoritative routing is (carrier, vendor, transaction) via docs/knowledge-base/gds-ndc-capability-matrix.md. ndc_version values here are historical seeds — do NOT treat as proven; never invent 21.3 for new rows. channel_priority is ordered preference for shopping|booking only.", + "_version": "0.1.1", "carriers": { "AA": { "name": "American Airlines", "channels": ["NDC", "GDS"], "channel_priority": ["NDC", "GDS"], "ndc_version": "21.3", "gds_preference": "SABRE", "ndc_capable": true, "ndc_provider_id": "NDC_AA" }, "BA": { "name": "British Airways", "channels": ["NDC", "GDS"], "channel_priority": ["NDC", "GDS"], "ndc_version": "21.3", "gds_preference": "AMADEUS", "ndc_capable": true, "ndc_provider_id": "NDC_BA" }, diff --git a/packages/agents/booking/src/gds-ndc-router/data/gds-ndc-capability-matrix.csv b/packages/agents/booking/src/gds-ndc-router/data/gds-ndc-capability-matrix.csv new file mode 100644 index 0000000..e495a08 --- /dev/null +++ b/packages/agents/booking/src/gds-ndc-router/data/gds-ndc-capability-matrix.csv @@ -0,0 +1,36 @@ +carrier,vendor,transaction,channel,ndc_version_notes,fallback,source,confidence +ANON-NDC-AGG,duffel,Shop,NDC,unknown (Duffel API version header != IATA NDC schema),GDS,docs/adapters/duffel.md,adapter_doc +ANON-NDC-AGG,duffel,OrderCreate,NDC,unknown,GDS,docs/adapters/duffel.md,adapter_doc +ANON-NDC-AGG,duffel,OrderChange,unknown,unknown,GDS,docs/adapters/duffel.md (exchange not implemented),adapter_doc +ANON-NDC-AGG,duffel,OrderCancel,unknown,unknown,GDS,docs/adapters/duffel.md + docs/architecture/ADAPTER_STATUS.md (flight cancel not implemented),adapter_doc +ANON-NDC-AGG,duffel,Servicing,GDS,n/a,none,docs/adapters/duffel.md (shop!=servicing: post-booking not on Duffel adapter path),adapter_doc +ANON-NDC-AGG,duffel,Groups,unknown,unknown,GDS,no public evidence,unknown +ANON-NDC-AGG,duffel,Corporate,unknown,unknown,GDS,no public evidence,unknown +ANON-SABRE-OO,sabre,Shop,Either,unknown (BFM may return NDC + ATPCO; schema per airline unknown),GDS,https://developer.sabre.com/guide/ndc/ndc.html + docs/adapters/sabre.md,vendor_public +ANON-SABRE-OO,sabre,OrderCreate,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/create),vendor_public +ANON-SABRE-OO,sabre,OrderChange,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/change),vendor_public +ANON-SABRE-OO,sabre,OrderCancel,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (v1/orders/cancel),vendor_public +ANON-SABRE-OO,sabre,Servicing,NDC,unknown,GDS,https://developer.sabre.com/guide/ndc/ndc.html (view/reshop/sync),vendor_public +ANON-SABRE-OO,sabre,Groups,unknown,unknown,GDS,no public evidence — often still classic GDS,unknown +ANON-SABRE-OO,sabre,Corporate,unknown,unknown,GDS,no public evidence,unknown +ANON-GDS-SS,amadeus,Shop,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,OrderCreate,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,OrderChange,unknown,n/a,none,docs/adapters/amadeus.md (exchange not in Self-Service),adapter_doc +ANON-GDS-SS,amadeus,OrderCancel,GDS,n/a,none,docs/adapters/amadeus.md,adapter_doc +ANON-GDS-SS,amadeus,Servicing,unknown,n/a,none,docs/adapters/amadeus.md (refund/exchange not in Self-Service),adapter_doc +ANON-GDS-SS,amadeus,Groups,unknown,n/a,none,no public evidence,unknown +ANON-GDS-SS,amadeus,Corporate,unknown,n/a,none,no public evidence,unknown +ANON-LCC-DIRECT,navitaire,Shop,Direct/API,n/a (AIDM order model not classic NDC XML version),none,docs/adapters/navitaire.md + docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderCreate,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderChange,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,OrderCancel,Direct/API,n/a,none,docs/adapters/navitaire.md + oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,Servicing,Direct/API,n/a,none,docs/adapters/oosd-navitaire.md,adapter_doc +ANON-LCC-DIRECT,navitaire,Groups,unknown,n/a,none,no public evidence,unknown +ANON-LCC-DIRECT,navitaire,Corporate,unknown,n/a,none,no public evidence,unknown +ANON-AGG-DUAL,trippro,Shop,Either,unknown (upstream may be GDS or NDC),GDS,docs/adapters/trippro.md,adapter_doc +ANON-AGG-DUAL,trippro,OrderCreate,Either,unknown,GDS,docs/adapters/trippro.md,adapter_doc +ANON-AGG-DUAL,trippro,OrderChange,unknown,unknown,GDS,docs/adapters/trippro.md (exchange not implemented),adapter_doc +ANON-AGG-DUAL,trippro,OrderCancel,GDS,n/a,none,docs/adapters/trippro.md (SOAP CancelPNR),adapter_doc +ANON-AGG-DUAL,trippro,Servicing,GDS,n/a,none,docs/adapters/trippro.md (SOAP ReadPNR/OrderTicket; shop!=servicing),adapter_doc +ANON-AGG-DUAL,trippro,Groups,unknown,unknown,GDS,no public evidence,unknown +ANON-AGG-DUAL,trippro,Corporate,unknown,unknown,GDS,no public evidence,unknown diff --git a/packages/agents/booking/src/gds-ndc-router/index.ts b/packages/agents/booking/src/gds-ndc-router/index.ts index 306f45c..1fef67c 100644 --- a/packages/agents/booking/src/gds-ndc-router/index.ts +++ b/packages/agents/booking/src/gds-ndc-router/index.ts @@ -30,6 +30,15 @@ const VALID_TRANSACTION_TYPES = new Set([ 'group', 'corporate', ]); +const VALID_VENDORS = new Set([ + 'sabre', + 'amadeus', + 'duffel', + 'navitaire', + 'trippro', + 'airline_direct', + 'unknown', +]); export class GdsNdcRouter implements Agent { readonly id = '3.1'; @@ -158,6 +167,22 @@ export class GdsNdcRouter implements Agent = new Set ({ + carrier: r.carrier, + vendor: r.vendor, + transaction: r.transaction, + channel: r.channel, + ndc_version_notes: r.ndc_version_notes, + fallback: r.fallback, + source: r.source ?? '', + confidence: r.confidence ?? 'unknown', + })); +} + function getCarrierConfig( iata: string, transactionType: TransactionType, overrides: GdsNdcRouterInput['capability_overrides'], + matrixRows: CapabilityMatrixRow[] | undefined, + vendor: MatrixVendor | undefined, + preferredChannel: DistributionChannel | undefined, ): CarrierChannelConfig | undefined { // Caller-supplied per-transaction override wins. const carrierOverrides = overrides?.[iata]; if (carrierOverrides && carrierOverrides[transactionType]) { return carrierOverrides[transactionType]; } + + // KB matrix: (carrier, vendor, transaction) — requires vendor. + if (matrixRows && vendor && vendor !== 'unknown') { + const fromMatrix = buildCapabilityOverridesFromMatrix( + matrixRows, + iata, + vendor, + preferredChannel, + ); + if (fromMatrix[transactionType]) { + return fromMatrix[transactionType]; + } + // If matrix has rows for this carrier+vendor but none resolve for this + // transaction (unknown / Either), do NOT fall through to the airline map. + const hasCarrierVendorRows = matrixRows.some( + (r) => r.carrier === iata && r.vendor === vendor, + ); + if (hasCarrierVendorRows) { + const labels = matrixTransactionsForAgentType(transactionType); + const hasUnknownOrEither = matrixRows.some( + (r) => + r.carrier === iata && + r.vendor === vendor && + labels.includes(r.transaction) && + (r.channel === 'unknown' || r.channel === 'Either'), + ); + if (hasUnknownOrEither) { + return undefined; + } + } + } + // Built-in carrier defaults apply ONLY to shopping/booking transactions. + // They are hints, not a per-airline channel map for all transactions. if (BUILTIN_TRANSACTION_TYPES.has(transactionType)) { return carrierData.carriers[iata]; } @@ -76,10 +135,41 @@ function resolveRoutingCarrier( segment: RoutingSegment, transactionType: TransactionType, overrides: GdsNdcRouterInput['capability_overrides'], + matrixRows: CapabilityMatrixRow[] | undefined, + vendor: MatrixVendor | undefined, + preferredChannel: DistributionChannel | undefined, + platingCarrier: string | undefined, ): { carrier: string; codeshare: boolean } { + // // TODO: DOMAIN_QUESTION: plating vs marketing vs operating channel precedence. + // When plating_carrier is supplied and differs, prefer it for matrix lookup. + if ( + platingCarrier && + platingCarrier !== segment.marketing_carrier && + platingCarrier !== segment.operating_carrier + ) { + const plateConfig = getCarrierConfig( + platingCarrier, + transactionType, + overrides, + matrixRows, + vendor, + preferredChannel, + ); + if (plateConfig) { + return { carrier: platingCarrier, codeshare: true }; + } + } + // Default strategy: use operating carrier if available if (segment.operating_carrier && segment.operating_carrier !== segment.marketing_carrier) { - const opConfig = getCarrierConfig(segment.operating_carrier, transactionType, overrides); + const opConfig = getCarrierConfig( + segment.operating_carrier, + transactionType, + overrides, + matrixRows, + vendor, + preferredChannel, + ); if (opConfig) { return { carrier: segment.operating_carrier, codeshare: true }; } @@ -105,20 +195,39 @@ function getBookingFormat(channel: DistributionChannel): 'GDS_PNR' | 'NDC_ORDER' export function routeSegments(input: GdsNdcRouterInput): GdsNdcRouterOutput { const routings: ChannelRouting[] = []; + const matrixRows = asMatrixRows(input.capability_matrix); + const vendor = input.vendor; for (const segment of input.segments) { const { carrier, codeshare } = resolveRoutingCarrier( segment, input.transaction_type, input.capability_overrides, + matrixRows, + vendor, + input.preferred_channel, + input.plating_carrier, + ); + const config = getCarrierConfig( + carrier, + input.transaction_type, + input.capability_overrides, + matrixRows, + vendor, + input.preferred_channel, ); - const config = getCarrierConfig(carrier, input.transaction_type, input.capability_overrides); if (!config) { // Two cases: - // 1. Transaction type beyond the built-in defaults and no override + // 1. Transaction type beyond the built-in defaults and no matrix/override // supplied → we cannot decide a channel. Return DOMAIN_INPUT_REQUIRED. // 2. Carrier truly unknown for shopping/booking → also DOMAIN_INPUT_REQUIRED. + const missing: string[] = [ + `capability_matrix|capability_overrides[${carrier}].${input.transaction_type}`, + ]; + if (!vendor || vendor === 'unknown') { + missing.push('vendor'); + } routings.push({ primary_channel: 'GDS', // placeholder; ignore when domain_input_required=true gds_system: null, @@ -129,9 +238,7 @@ export function routeSegments(input: GdsNdcRouterInput): GdsNdcRouterOutput { codeshare_applied: codeshare, booking_format: 'GDS_PNR', domain_input_required: true, - missing_inputs: [ - `capability_overrides[${carrier}].${input.transaction_type}`, - ], + missing_inputs: missing, }); continue; } @@ -244,7 +351,9 @@ function buildNdcFormatStub( ); if (ndcRoutings.length === 0) return null; - const version = ndcRoutings[0]!.ndc_version ?? '21.3'; + // Never invent a schema version (CLAUDE.md / #142: no "everyone is 21.3"). + const version = ndcRoutings[0]!.ndc_version; + if (!version) return null; return { format: 'NDC_ORDER', diff --git a/packages/agents/booking/src/gds-ndc-router/schema.ts b/packages/agents/booking/src/gds-ndc-router/schema.ts index 1c070bd..77d3f7a 100644 --- a/packages/agents/booking/src/gds-ndc-router/schema.ts +++ b/packages/agents/booking/src/gds-ndc-router/schema.ts @@ -7,6 +7,23 @@ import { z } from 'zod'; const channelSchema = z.enum(['GDS', 'NDC', 'DIRECT']); const gdsSchema = z.enum(['AMADEUS', 'SABRE', 'TRAVELPORT']); const ndcVersionSchema = z.enum(['17.2', '18.1', '21.3']); +const transactionTypeSchema = z.enum([ + 'shopping', + 'booking', + 'ticketing', + 'servicing', + 'group', + 'corporate', +]); +const vendorSchema = z.enum([ + 'sabre', + 'amadeus', + 'duffel', + 'navitaire', + 'trippro', + 'airline_direct', + 'unknown', +]); const routingSegmentSchema = z.object({ marketing_carrier: z.string().min(2).max(3), @@ -16,8 +33,31 @@ const routingSegmentSchema = z.object({ flight_number: z.string().optional(), }); +const capabilityMatrixRowSchema = z.object({ + carrier: z.string().min(1), + vendor: vendorSchema, + transaction: z.enum([ + 'Shop', + 'OrderCreate', + 'OrderChange', + 'OrderCancel', + 'Servicing', + 'Groups', + 'Corporate', + ]), + channel: z.enum(['NDC', 'GDS', 'Direct/API', 'Either', 'unknown']), + ndc_version_notes: z.string(), + fallback: z.string(), + source: z.string().optional(), + confidence: z.enum(['adapter_doc', 'vendor_public', 'unknown']).optional(), +}); + export const gdsNdcRouterInputSchema = z.object({ segments: z.array(routingSegmentSchema).min(1), + transaction_type: transactionTypeSchema, + vendor: vendorSchema.optional(), + plating_carrier: z.string().min(2).max(3).optional(), + capability_matrix: z.array(capabilityMatrixRowSchema).optional(), preferred_channel: channelSchema.optional(), preferred_gds: gdsSchema.optional(), include_fallbacks: z.boolean(), @@ -63,6 +103,8 @@ const channelRoutingSchema = z.object({ routed_carrier: z.string(), codeshare_applied: z.boolean(), booking_format: z.enum(['GDS_PNR', 'NDC_ORDER', 'DIRECT_API']), + domain_input_required: z.boolean().optional(), + missing_inputs: z.array(z.string()).optional(), }); export const gdsNdcRouterOutputSchema = z.object({ diff --git a/packages/agents/booking/src/gds-ndc-router/types.ts b/packages/agents/booking/src/gds-ndc-router/types.ts index 2f1857a..a743324 100644 --- a/packages/agents/booking/src/gds-ndc-router/types.ts +++ b/packages/agents/booking/src/gds-ndc-router/types.ts @@ -2,6 +2,10 @@ * GDS/NDC Router — Types * * Agent 3.1: Routes booking requests to the correct distribution channel. + * + * Authoritative matrix: docs/knowledge-base/gds-ndc-capability-matrix.md + * Lookup key is (carrier, vendor, transaction) — never airline → channel alone. + * Res 787 names the Shop / Order / Change processes; it is not a parity matrix. */ export type DistributionChannel = 'GDS' | 'NDC' | 'DIRECT'; @@ -10,17 +14,31 @@ export type NdcVersion = '17.2' | '18.1' | '21.3'; export type GdsSystem = 'AMADEUS' | 'SABRE' | 'TRAVELPORT'; +/** + * Distribution vendor / intermediary that executes the transaction. + * Required for unambiguous matrix lookup when a carrier appears on + * multiple vendors (e.g. Sabre Offer/Order vs Duffel vs airline direct). + */ +export type DistributionVendor = + | 'sabre' + | 'amadeus' + | 'duffel' + | 'navitaire' + | 'trippro' + | 'airline_direct' + | 'unknown'; + /** * Transaction-level routing dimension. Channel choice depends on the - * type of operation, not just the carrier — most NDC carriers still - * require GDS for groups, corporate fares, and post-booking servicing - * even when their default shopping/booking flow is NDC. + * type of operation AND vendor, not just the carrier — most NDC carriers + * still require GDS for groups, corporate fares, and post-booking + * servicing even when their default shopping/booking flow is NDC. * - * // DOMAIN_QUESTION: per-carrier capability matrix per transaction type - * // (groups, corporate, post-booking servicing). The built-in carrier - * // map covers 'shopping' and 'booking' only — every other transaction - * // type requires the caller to supply `transaction_capability_overrides`, - * // otherwise the engine returns DOMAIN_INPUT_REQUIRED. + * Engine aliases map to Res 787 / KB matrix labels: + * shopping → Shop, booking → OrderCreate, + * servicing → Servicing|OrderChange|OrderCancel, + * group → Groups, corporate → Corporate. + * See docs/knowledge-base/gds-ndc-capability-matrix.md. */ export type TransactionType = | 'shopping' @@ -34,6 +52,7 @@ export interface CarrierChannelConfig { name: string; channels: DistributionChannel[]; channel_priority: DistributionChannel[]; + /** Concrete schema version only — never invent (no default 21.3). */ ndc_version: NdcVersion | null; gds_preference: GdsSystem | null; ndc_capable: boolean; @@ -56,26 +75,66 @@ export interface RoutingSegment { /** * Per-transaction-type capability override map. Caller supplies this when * routing transaction types that the built-in carrier defaults don't cover - * ('ticketing', 'servicing', 'group', 'corporate'). Entries take the same - * shape as the built-in carrier defaults. + * ('ticketing', 'servicing', 'group', 'corporate'), or when consuming the + * KB matrix via capability-matrix.ts helpers. */ export type TransactionCapabilityOverrides = Partial< Record >; +/** + * Inline matrix row shape accepted on input (same columns as the KB CSV). + * Prefer this over a single airline→channel map. + */ +export interface CapabilityMatrixInputRow { + carrier: string; + vendor: DistributionVendor; + transaction: + | 'Shop' + | 'OrderCreate' + | 'OrderChange' + | 'OrderCancel' + | 'Servicing' + | 'Groups' + | 'Corporate'; + channel: 'NDC' | 'GDS' | 'Direct/API' | 'Either' | 'unknown'; + ndc_version_notes: string; + fallback: string; + source?: string; + confidence?: 'adapter_doc' | 'vendor_public' | 'unknown'; +} + export interface GdsNdcRouterInput { /** Segments to route */ segments: RoutingSegment[]; /** - * Transaction type being routed. The decision is per-transaction, not - * per-airline: a carrier may use NDC for shopping but require GDS for - * groups or post-booking servicing. + * Transaction type being routed. The decision is per-(carrier, vendor, + * transaction), not per-airline: a carrier may use NDC for shopping but + * require GDS for groups or post-booking servicing. */ transaction_type: TransactionType; + /** + * Vendor / intermediary executing the transaction. Required for + * unambiguous matrix lookup when the same carrier has rows under + * multiple vendors. + */ + vendor?: DistributionVendor; + /** + * Plating carrier when distinct from marketing/operating. + * // TODO: DOMAIN_QUESTION: plating vs marketing vs operating channel precedence + */ + plating_carrier?: string; + /** + * Inline capability matrix rows (KB CSV shape). When present, used to + * resolve channel for (carrier, vendor, transaction) before falling back + * to capability_overrides / built-in shopping|booking hints. + */ + capability_matrix?: CapabilityMatrixInputRow[]; /** * Caller-supplied capability overrides keyed by carrier IATA, then by * transaction type. Required for transaction types beyond - * 'shopping'/'booking' — the engine has no built-in defaults for them. + * 'shopping'/'booking' when no matrix row resolves — otherwise the + * engine returns DOMAIN_INPUT_REQUIRED. */ capability_overrides?: Record; /** Preferred channel (optional override) */ diff --git a/packages/agents/booking/src/index.ts b/packages/agents/booking/src/index.ts index ba99c48..d7d7881 100644 --- a/packages/agents/booking/src/index.ts +++ b/packages/agents/booking/src/index.ts @@ -42,6 +42,24 @@ export { gdsNdcRouterOutputSchema, } from './gds-ndc-router/schema.js'; export { buildCarrierCapabilities } from './gds-ndc-router/registry-adapter.js'; +export { + getSeedCapabilityMatrix, + parseCapabilityMatrixCsv, + lookupMatrixRow, + matrixRowToCarrierConfig, + buildCapabilityOverridesFromMatrix, + MATRIX_TO_AGENT_TRANSACTION, + matrixTransactionsForAgentType, + parseNdcVersionNotes, +} from './gds-ndc-router/capability-matrix.js'; +export type { + CapabilityMatrixRow, + MatrixTransaction, + MatrixVendor, + MatrixChannel, + CapabilityMatrixInputRow, + DistributionVendor, +} from './gds-ndc-router/index.js'; export { PnrBuilder } from './pnr-builder/index.js'; export type { From e61f3d76bc2dc4d047e7a02915c060ace66fbcc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:07:15 +0000 Subject: [PATCH 2/4] chore: regenerate agent manifest for GdsNdcRouter matrix inputs Reflect transaction_type, vendor, plating_carrier, and capability_matrix on Agent 3.1 schema after #142 KB matrix work. Co-authored-by: telivity-otaip --- agents.manifest.json | 110 +++++++++++++++++++++++++++++++++++++++++++ docs/agent-map.html | 2 +- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/agents.manifest.json b/agents.manifest.json index a01fe5b..a0a814e 100644 --- a/agents.manifest.json +++ b/agents.manifest.json @@ -2806,6 +2806,106 @@ "additionalProperties": false } }, + "transaction_type": { + "type": "string", + "enum": [ + "shopping", + "booking", + "ticketing", + "servicing", + "group", + "corporate" + ] + }, + "vendor": { + "type": "string", + "enum": [ + "sabre", + "amadeus", + "duffel", + "navitaire", + "trippro", + "airline_direct", + "unknown" + ] + }, + "plating_carrier": { + "type": "string", + "minLength": 2, + "maxLength": 3 + }, + "capability_matrix": { + "type": "array", + "items": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "minLength": 1 + }, + "vendor": { + "type": "string", + "enum": [ + "sabre", + "amadeus", + "duffel", + "navitaire", + "trippro", + "airline_direct", + "unknown" + ] + }, + "transaction": { + "type": "string", + "enum": [ + "Shop", + "OrderCreate", + "OrderChange", + "OrderCancel", + "Servicing", + "Groups", + "Corporate" + ] + }, + "channel": { + "type": "string", + "enum": [ + "NDC", + "GDS", + "Direct/API", + "Either", + "unknown" + ] + }, + "ndc_version_notes": { + "type": "string" + }, + "fallback": { + "type": "string" + }, + "source": { + "type": "string" + }, + "confidence": { + "type": "string", + "enum": [ + "adapter_doc", + "vendor_public", + "unknown" + ] + } + }, + "required": [ + "carrier", + "vendor", + "transaction", + "channel", + "ndc_version_notes", + "fallback" + ], + "additionalProperties": false + } + }, "preferred_channel": { "type": "string", "enum": [ @@ -2828,6 +2928,7 @@ }, "required": [ "segments", + "transaction_type", "include_fallbacks" ], "additionalProperties": false @@ -2913,6 +3014,15 @@ "NDC_ORDER", "DIRECT_API" ] + }, + "domain_input_required": { + "type": "boolean" + }, + "missing_inputs": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..05b37f4 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- +