From 17d43665f49c052cb05820dd44f75f9933317f70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:02:06 +0000 Subject: [PATCH 1/5] feat(adm-prevention): complete passive/UC/churning detection (#145) Add KB-backed host status matrix (HX/UC/UN/NO/TK + extended), history-required churning, Travelport DX marriage note, and TTL deadline-day timezone checks. Fixtures encode scariest false negatives. No carrier-secret commission tables. Co-authored-by: telivity-otaip --- README.md | 2 +- docs/agents.md | 2 +- docs/agents/stage-6-settlement.md | 24 +- docs/knowledge-base/adm-prevention.md | 121 +++++++++ .../__tests__/adm-prevention.test.ts | 249 ++++++++++++++++-- .../__tests__/fixtures/churn-all-hk-now.json | 88 +++++++ .../fixtures/travelport-dx-marriage.json | 41 +++ .../fixtures/ttl-deadline-day-tz.json | 32 +++ .../__tests__/fixtures/uc-hn-passive-pk.json | 47 ++++ .../__tests__/fixtures/uncleared-tk.json | 29 ++ .../src/adm-prevention/audit-engine.ts | 198 ++++++++++++-- .../settlement/src/adm-prevention/index.ts | 20 +- .../src/adm-prevention/status-codes.ts | 62 +++++ .../settlement/src/adm-prevention/types.ts | 62 ++++- packages/agents/settlement/src/index.ts | 14 +- 15 files changed, 945 insertions(+), 46 deletions(-) create mode 100644 docs/knowledge-base/adm-prevention.md create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/churn-all-hk-now.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/travelport-dx-marriage.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/ttl-deadline-day-tz.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uncleared-tk.json create mode 100644 packages/agents/settlement/src/adm-prevention/status-codes.ts diff --git a/README.md b/README.md index 95c388f..88abd96 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ See [docs/architecture.md](docs/architecture.md) for the full architecture overv ## Domain Expertise -OTAIP agents encode real industry logic: ATPCO fare rules (Categories 1-33), NUC/ROE fare construction with HIP/BHC/CTM checks, BSP HOT file reconciliation, ADM prevention (9 pre-ticketing checks), NDC/EDIFACT normalization, IRROPS rebooking with EU261 and US DOT compliance, void window enforcement, married segment integrity, and payment-to-ticketing state machines with BSP finality rules. +OTAIP agents encode real industry logic: ATPCO fare rules (Categories 1-33), NUC/ROE fare construction with HIP/BHC/CTM checks, BSP HOT file reconciliation, ADM prevention (10 pre-ticketing checks), NDC/EDIFACT normalization, IRROPS rebooking with EU261 and US DOT compliance, void window enforcement, married segment integrity, and payment-to-ticketing state machines with BSP finality rules. --- diff --git a/docs/agents.md b/docs/agents.md index f554c09..81e10bd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -83,7 +83,7 @@ | ID | Class | Name | Description | Contract | |----|-------|------|-------------|----------| | 6.1 | `RefundProcessing` | Refund Processing | ATPCO Category 33 refund processing: penalty application, commission recall, BSP/ARC reporting, conjunction ticket handling | -- | -| 6.2 | `ADMPrevention` | ADM Prevention | Pre-ticketing audit: 9 checks covering fare integrity, segment validity, and compliance to prevent Agency Debit Memos | -- | +| 6.2 | `ADMPrevention` | ADM Prevention | Pre-ticketing audit: 10 checks covering fare integrity, passive/UC/churn (history-required), married integrity, TTL, and compliance to prevent Agency Debit Memos | -- | | 6.3 | `ADMACMProcessingAgent` | ADM/ACM Processing | Agency Debit Memo receipt, assessment, dispute, and Agency Credit Memo application workflows | -- | | 6.4 | `CustomerCommunication` | Customer Communication | Multi-channel customer notification generation for flight disruptions, refunds, and operational changes. 8 notification types x 4 channels | -- | | 6.5 | `FeedbackComplaintAgent` | Feedback & Complaint | Complaint submission, EU261/US DOT compensation calculation, case management, and regulatory DOT record generation | -- | diff --git a/docs/agents/stage-6-settlement.md b/docs/agents/stage-6-settlement.md index b9ac7dd..fadce84 100644 --- a/docs/agents/stage-6-settlement.md +++ b/docs/agents/stage-6-settlement.md @@ -15,6 +15,7 @@ Refund processing, ADM prevention, ADM/ACM dispute management, customer communic ATPCO Category 33 refund processing: penalty application, commission recall, BSP/ARC reporting fields, conjunction ticket handling. Supports full, partial, and tax-only refunds. **Input (`RefundProcessingInput`):** + - `ticket_number`, `conjunction_tickets?` - `issuing_carrier`, `passenger_name`, `record_locator` - `base_fare`, `base_fare_currency`, `taxes`, `commission?` @@ -24,6 +25,7 @@ ATPCO Category 33 refund processing: penalty application, commission recall, BSP - `settlement_system` -- `'BSP' | 'ARC'` **Output (`RefundProcessingOutput`):** + - `refund` -- penalty applied, base fare refund, tax refund, tax breakdown, commission recalled, net refund, BSP/ARC reporting fields, audit trail - `net_refund_amount`, `commission_recalled` @@ -35,17 +37,25 @@ ATPCO Category 33 refund processing: penalty application, commission recall, BSP **Class:** `ADMPrevention` **Status:** Implemented -Pre-ticketing audit with 9 checks: duplicate booking, fare/class mismatch, passive segment, married segment integrity, TTL expiry, commission rate, endorsement box, tour code format, net remit validation. +Pre-ticketing audit with 10 checks: duplicate booking, fare/class mismatch, passive/unable/risky status (HX/UC/UN/NO/TK + extended), churning (history-required), married segment integrity, TTL expiry (timezone / deadline-day), commission rate (caller-supplied only), endorsement box, tour code format, net remit validation. + +**Domain KB:** `docs/knowledge-base/adm-prevention.md` — IATA Reso 850m covers ADM memo windows/dispute (Agent 6.3); passive/UC/churn come from carrier booking policy + host statuses. Travelport: those statuses do not need a ticketing field. No carrier-secret commission tables. **Input (`ADMPreventionInput`):** + - `booking` -- record locator, passenger name, segments (with status, class, married group), base fare - `fare_basis`, `booked_class` -- `commission_rate?`, `carrier_contracted_rate?` +- `commission_rate?`, `carrier_contracted_rate?` -- both caller-supplied; no embedded carrier tables - `endorsement?`, `tour_code?` - `is_net_remit?`, `net_contracted_amount?` -- `ttl_deadline?`, `duplicate_check_pnrs?`, `current_datetime?` +- `ttl_deadline?`, `ttl_timezone?`, `ttl_source?`, `current_datetime?` +- `duplicate_check_pnrs?` +- `segment_history?` -- required for churning; current status alone is insufficient +- `gds?` -- `SABRE` | `AMADEUS` | `TRAVELPORT` | `UNKNOWN` (Travelport DX marriage break) +- `churn_cycle_threshold?`, `churn_window_hours?` **Output (`ADMPreventionOutput`):** + - `result` -- all check results, overall pass/fail, blocking/warning counts --- @@ -59,10 +69,12 @@ Pre-ticketing audit with 9 checks: duplicate booking, fare/class mismatch, passi Agency Debit Memo receipt, assessment, dispute, and Agency Credit Memo application workflows. Tracks dispute deadlines (15-day window), supports dispute grounds, and manages status transitions. **Input (`ADMACMProcessingInput`):** + - `operation` -- `'receiveADM' | 'receiveACM' | 'assessADM' | 'disputeADM' | 'acceptADM' | 'escalateADM' | 'applyACM' | 'getADM' | 'getPendingWithDeadlines'` - Operation-specific fields: ticket number, airline, amount, reason code, dispute ground/evidence, ADM/ACM IDs **Output (`ADMACMProcessingOutput`):** + - `adm?` -- ADM record with status history - `acm?` -- ACM record - `assessment?` -- days remaining, window expired, recommended action @@ -80,6 +92,7 @@ Agency Debit Memo receipt, assessment, dispute, and Agency Credit Memo applicati Multi-channel customer notification generation. 8 notification types (flight cancelled, delayed, gate change, rebooking confirmed, refund processed, schedule change, waitlist cleared, ADM received) x 4 channels (Email HTML, Email text, SMS, WhatsApp). **Input (`CustomerCommunicationInput`):** + - `operation` -- `'generateNotification' | 'generateBatch' | 'getTemplate'` - `notificationType?` -- notification type - `channel?` -- delivery channel @@ -87,6 +100,7 @@ Multi-channel customer notification generation. 8 notification types (flight can - `batchRequests?` -- for batch generation **Output (`CustomerCommunicationOutput`):** + - `notification?` -- generated notification with body, subject, SMS segments, used/missing variables - `notifications?` -- batch results - `template?` -- template info with required variables @@ -102,12 +116,14 @@ Multi-channel customer notification generation. 8 notification types (flight can Complaint submission, EU261/US DOT compensation calculation, case management with status tracking, and DOT regulatory record generation. **Input (`FeedbackComplaintInput`):** + - `operation` -- `'submitComplaint' | 'updateStatus' | 'getCase' | 'listCases' | 'calculateCompensation' | 'generateDOTRecord'` - Complaint fields: type, passenger, booking reference, airline, flight, description - Compensation fields: regulation, distance, delay, alternative offered, fare paid, cabin class - Case management: case ID, status transitions **Output (`FeedbackComplaintOutput`):** + - `complaintCase?` -- full case record with status history, compensation result - `cases?` -- filtered case list - `compensation?` -- EU261/US DOT calculation (eligibility, base/final amount, reduction, notes) @@ -124,6 +140,7 @@ Complaint submission, EU261/US DOT compensation calculation, case management wit Mileage accrual calculation, redemption eligibility checking, status benefits lookup, and cross-airline status matching. **Input (`LoyaltyMileageInput`):** + - `operation` -- `'calculateAccrual' | 'checkRedemptionEligibility' | 'getStatusBenefits' | 'matchStatus'` - Accrual: operating/crediting carrier, booking class, distance, loyalty status - Redemption: distance, cabin, partner flag, current balance @@ -131,6 +148,7 @@ Mileage accrual calculation, redemption eligibility checking, status benefits lo - Match: source/target airline, source status **Output (`LoyaltyMileageOutput`):** + - `accrual?` -- base miles, bonus miles, total, earn rate, partner flag - `redemption?` -- eligibility, miles required, remaining balance - `statusBenefits?` -- benefit list by tier diff --git a/docs/knowledge-base/adm-prevention.md b/docs/knowledge-base/adm-prevention.md new file mode 100644 index 0000000..4a855d2 --- /dev/null +++ b/docs/knowledge-base/adm-prevention.md @@ -0,0 +1,121 @@ +# ADM Prevention — Domain Knowledge (Agent 6.2) + +Authoritative input for `@otaip/agents-settlement` ADM Prevention. Sources: IATA Resolution 850m (ADM process), public GDS status/advice tables (Amadeus Service Hub; Travelport Universal API PNR Status Codes), and mid-office practice for carrier booking-policy ADMs. Anything missing is an open `DOMAIN_QUESTION` — never invent carrier-secret fee or commission tables. + +## Scope split (do not conflate) + +| Concern | Authority | What Agent 6.2 uses it for | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| ADM **memo** lifecycle — issuance windows, BSP processing limits, agent dispute period | **IATA Resolution 850m** | Context only for Agent 6.3 (dispute deadlines). 850m does **not** define passive/UC/churn rules. | +| Passive / UC / unable / schedule-change / churning ADMs | **Carrier booking & ticketing policies** + **GDS host segment status codes** | Pre-ticketing checks in Agent 6.2 | + +Typical 850m process facts used elsewhere in OTAIP (Agent 6.3): agent review/dispute window **15 days** before BSP submission; airline dispute handling expected in a timely manner (commonly up to **60 days** in carrier policies that cite 850m); BSP processing generally within **nine months** of final travel (or refund) date. Carrier policies may add market-specific overlays — do not hardcode carrier dispute SLAs here. + +## Host statuses that block ticketing (core set) + +Carrier booking policies and host queues treat these **advice / status** codes as must-clear before issue. Detection is from the **segment status on the PNR**, not from inventing a parallel “ticketing field.” + +| Code | Typical meaning (public GDS tables) | ADM / mid-office risk | +| ------ | ------------------------------------------------------- | --------------------------------------------------------------- | +| **HX** | Holding canceled / cancel confirm hold | Dead or rejected space — remove before ticketing | +| **UC** | Unable to confirm (often flight closed, not waitlisted) | No confirmed inventory — remove / rebook | +| **UN** | Unable — flight does not operate / no flight | Schedule or sell failure — remove / reprotect | +| **NO** | No action taken (common airline reject of passive) | Passive/claim rejected — delete NO segments | +| **TK** | Schedule change — advise passenger of new times | Uncleared schedule change — passenger not advised / times stale | + +**Travelport note:** On Travelport hosts, the statuses above are sufficient for detection — they do **not** require an additional ticketing-specific field on the segment. Status alone drives the passive/unable/schedule-change queue action. + +### Also flag before ticketing (extended matrix) + +Beyond the core set, public GDS docs list codes that are passive-entry, pending, or otherwise unsafe to ticket. Agent 6.2 treats these as blocking when present on air segments: + +| Code | Role | Notes (public docs) | +| --------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| **HN** | Holding need / pending need | Not confirmed — CLAUDE Agent 6.2 guard: must check HN, not only HX/UN/NO | +| **PK / PL** | Passive confirmed / waitlisted (Amadeus) | Passive sell — airline may reply NO/UC/UN/HX | +| **GK / GL / GN** | Ghost / guaranteed passive-style | Amadeus ghost; Sabre/Travelport meanings vary by host | +| **YK** | Sabre administrative / itinerary passive (common ops usage) | Often used for invoice/itinerary, not live sell | +| **AK / AL / AN** | Travelport Galileo passive outside system | Confirmed / waitlisted / requested outside 1G | +| **BK / BL** | Travelport passive booked / waitlist with carrier | Cancel may message carrier | +| **MK / PS / ZK / LK** | Travelport passive / non-messaging / API / link | Host-specific passive families | +| **DX** | Travelport: passive broken marriage **or** authorized partial cancel in marriage | Married-integrity signal — see below | +| **UU / US** | Unable, waitlisted / unable to sell | Not sellable confirmed space | +| **XX / XK** | Cancel segment / cancel with change | Residual cancel statuses must be cleaned | + +Active / ticketable examples (not exhaustive): **HK**, **KK**, **KL**, **RR**, **HS** (context-dependent). Do not treat “looks confirmed now” as proof the PNR was never churned — see churning. + +## Churning (requires history) + +**Definition (ops):** Repeated book → cancel → rebook of the same (or equivalent) air space to circumvent TTL, hold inventory, or game GDS productivity — without intent to ticket the intermediate holds. + +**Hard rule:** Churning **cannot** be detected from **current** segment status alone. A PNR that is all `HK` today can still be a churn case if the history shows multiple cancel/rebook cycles on the same carrier/flight/date (or same O&D class) inside a short window. + +Minimum history signals Agent 6.2 expects (when provided): + +1. Ordered segment history events: `BOOKED` | `CANCELLED` | `REBOOKED` (ISO timestamps). +2. Flight identity: carrier + flight number + departure date (and optionally origin/destination). +3. Default detection heuristic (conservative, not carrier-secret): **≥ 3 cancel→rebook cycles** on the same flight identity within **72 hours** → blocking `CHURNING` failure. + +If history is omitted, churning is **skipped** (not assumed clear). Callers that need churn coverage must supply history — skipping is an explicit ADM exposure. + +``` +// TODO: DOMAIN_QUESTION: Carrier-specific churn thresholds (cycle count / window hours) +// vary by airline booking policy. Do not hardcode a global carrier table; keep the +// default heuristic overridable once policy feeds exist. +``` + +## Married segments (GDS-specific) + +Married segments are **host constructs**, not “segments that happen to be in the same PNR.” + +| GDS | What mid-office looks for | Break risk | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| **Sabre** | Married Segment Indicator (MSI) / marriage linkage on connecting sells | Partial cancel or class change on one leg of a marriage → married-segment ADM under many carrier policies | +| **Amadeus** | Married connection / marriage control on Air Sell; status must stay consistent across the married set | Splitting or mixed advice codes across the marriage | +| **Travelport** | Marriage grouping on the air segment; status **DX** can mean _broken marriage_ (passive) or authorized partial cancel within marriage | Seeing **DX** (or mixed HK + cancel advice inside one marriage group) is a red flag for integrity review | + +Agent 6.2 minimum check: every `married_group` must have identical statuses and at least two segments. When `gds` is `TRAVELPORT` and any segment status is `DX`, treat as married-integrity failure even if a group id is missing. + +``` +// TODO: DOMAIN_QUESTION: Exact Sabre MSI field names in OTAIP normalized PNR +// vs cryptic *IA display — confirm adapter mapping before relying on MSI alone. +``` + +## TTL edge cases (timezone / deadline day) + +Carriers set TTL at booking, fare quote, or by departure rules — interpretation is carrier-specific. Agent 6.2 applies these operational guards when inputs are present: + +1. Compare `current_datetime` to `ttl_deadline` in absolute UTC instants when both are full ISO-8601 timestamps. +2. If `ttl_timezone` is provided (IANA name), also evaluate **calendar deadline-day** in that zone: ticketing **on the same local calendar date as the deadline** is ADM-prone even when a few hours remain in UTC (carriers often ADM same-day-of-deadline issuance). +3. Distinguish `ttl_source`: `BOOKING` vs `FARE_QUOTE` vs `CARRIER_RULE` in messages — do not silently assume one source. +4. Keep a pre-issue buffer (currently 30 minutes) so the PNR cannot expire mid-transaction. + +``` +// TODO: DOMAIN_QUESTION: Default agency timezone vs airport-of-origin vs BSP +// market clock when ttl_timezone is absent — Agent 8.3 mid-office may share this policy. +``` + +## Commission + +Commission validation remains **input-driven**: compare supplied `commission_rate` to caller-supplied `carrier_contracted_rate`. + +**Do not** paste or hardcode carrier-secret commission tables, override grids, or net-remit contract schedules into this repository. + +## Fixtures (scariest false negatives) + +See `packages/agents/settlement/src/adm-prevention/__tests__/fixtures/`: + +| Fixture | Why it is scary | +| ----------------------------- | --------------------------------------------------------------------------- | +| `churn-all-hk-now.json` | Current statuses all HK — HX/UN/NO-only scanners pass; history proves churn | +| `uncleared-tk.json` | TK schedule-change left on PNR — not in classic HX/UN/NO set | +| `uc-hn-passive-pk.json` | UC + HN + PK together — beyond HX/UN/NO | +| `travelport-dx-marriage.json` | Travelport DX / broken marriage without needing a ticketing field | +| `ttl-deadline-day-tz.json` | UTC still “has time” but local deadline day has begun | + +## Open DOMAIN_QUESTIONs + +1. Per-carrier churn cycle/window overrides (policy feed shape). +2. Sabre MSI normalized field mapping across adapters. +3. Default timezone when `ttl_timezone` omitted (agency vs origin airport vs BSP market). +4. Whether legitimate passive-for-ticketing (host claim / airline-held PNR) should warn instead of block when an airline record locator claim is present — needs claim-flow design, not a guess. diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts b/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts index a7bb646..7ad06f5 100644 --- a/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts +++ b/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts @@ -1,12 +1,19 @@ /** * ADM Prevention — Unit Tests * - * Agent 6.2: 9 pre-ticketing audit checks. + * Agent 6.2: 10 pre-ticketing audit checks. + * Domain: docs/knowledge-base/adm-prevention.md */ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ADMPrevention } from '../index.js'; -import type { ADMPreventionInput, BookingRecord, BookingSegment } from '../types.js'; +import { ADMPrevention, ADM_CHECK_COUNT, CORE_BLOCKING_STATUSES } from '../index.js'; +import type { ADMPreventionInput, BookingRecord, BookingSegment, ADMCheckId } from '../types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = join(__dirname, 'fixtures'); let agent: ADMPrevention; @@ -135,7 +142,7 @@ describe('ADM Prevention', () => { }); }); - describe('Check 3: Passive segment abuse', () => { + describe('Check 3: Passive / unable / risky status', () => { it('passes with active segments', async () => { const result = await agent.execute({ data: makeInput() }); const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); @@ -159,9 +166,152 @@ describe('ADM Prevention', () => { const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); expect(check!.passed).toBe(false); }); + + it('fails with core set UC / NO / TK', async () => { + expect([...CORE_BLOCKING_STATUSES].sort()).toEqual(['HX', 'NO', 'TK', 'UC', 'UN'].sort()); + for (const status of ['UC', 'NO', 'TK'] as const) { + const input = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(false); + expect(check!.reason).toContain(status); + } + }); + + it('fails with HN pending need', async () => { + const input = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status: 'HN' })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(false); + }); + + it('fails with Amadeus passive PK', async () => { + const input = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status: 'PK' })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(false); + }); + + it('notes Travelport needs no ticketing field', async () => { + const input = makeInput({ + gds: 'TRAVELPORT', + booking: makeBooking({ segments: [makeSegment({ status: 'UC' })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(false); + expect(check!.reason).toMatch(/no ticketing field/i); + }); }); - describe('Check 4: Married segment integrity', () => { + describe('Check 4: Churning (history-required)', () => { + it('skips when no segment history (does not assume clear from HK alone)', async () => { + const result = await agent.execute({ data: makeInput() }); + const check = result.data.result.checks.find((c) => c.check_id === 'CHURNING'); + expect(check!.passed).toBe(true); + expect(check!.reason).toMatch(/skipped/i); + expect(check!.reason).toMatch(/history/i); + }); + + it('fails when history shows cancel→rebook cycles on same flight', async () => { + const input = makeInput({ + segment_history: [ + { + timestamp: '2026-03-28T08:00:00Z', + action: 'BOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-28T10:00:00Z', + action: 'CANCELLED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-28T10:05:00Z', + action: 'REBOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-29T09:00:00Z', + action: 'CANCELLED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-29T09:10:00Z', + action: 'REBOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-30T11:00:00Z', + action: 'CANCELLED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-30T11:15:00Z', + action: 'REBOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + ], + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'CHURNING'); + expect(check!.passed).toBe(false); + expect(check!.severity).toBe('blocking'); + }); + + it('passes when history has fewer than threshold cycles', async () => { + const input = makeInput({ + segment_history: [ + { + timestamp: '2026-03-28T08:00:00Z', + action: 'BOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-28T10:00:00Z', + action: 'CANCELLED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + { + timestamp: '2026-03-28T10:05:00Z', + action: 'REBOOKED', + carrier: 'BA', + flight_number: '115', + departure_date: '2026-06-15', + }, + ], + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'CHURNING'); + expect(check!.passed).toBe(true); + }); + }); + + describe('Check 5: Married segment integrity', () => { it('passes when married segments have same status', async () => { const input = makeInput({ booking: makeBooking({ @@ -189,9 +339,22 @@ describe('ADM Prevention', () => { const check = result.data.result.checks.find((c) => c.check_id === 'MARRIED_SEGMENT'); expect(check!.passed).toBe(false); }); + + it('fails Travelport DX as marriage break (no ticketing field)', async () => { + const input = makeInput({ + gds: 'TRAVELPORT', + booking: makeBooking({ + segments: [makeSegment({ status: 'DX', married_group: 'M1' })], + }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'MARRIED_SEGMENT'); + expect(check!.passed).toBe(false); + expect(check!.reason).toMatch(/Travelport DX/i); + }); }); - describe('Check 5: TTL expired', () => { + describe('Check 6: TTL expired', () => { it('passes when TTL is in the future', async () => { const input = makeInput({ ttl_deadline: '2026-04-02T12:00:00Z' }); const result = await agent.execute({ data: input }); @@ -219,9 +382,22 @@ describe('ADM Prevention', () => { expect(check!.passed).toBe(true); expect(check!.reason).toContain('skipped'); }); + + it('fails on local deadline-day even when UTC has remaining hours', async () => { + const input = makeInput({ + current_datetime: '2026-04-01T16:00:00Z', + ttl_deadline: '2026-04-02T00:00:00Z', + ttl_timezone: 'America/New_York', + ttl_source: 'BOOKING', + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'TTL_EXPIRED'); + expect(check!.passed).toBe(false); + expect(check!.reason).toMatch(/deadline-day/i); + }); }); - describe('Check 6: Commission rate', () => { + describe('Check 7: Commission rate', () => { it('passes when commission within contracted rate', async () => { const input = makeInput({ commission_rate: 5, carrier_contracted_rate: 7 }); const result = await agent.execute({ data: input }); @@ -236,15 +412,16 @@ describe('ADM Prevention', () => { expect(check!.passed).toBe(false); }); - it('skips when no commission data', async () => { + it('skips when no commission data and does not embed carrier tables', async () => { const result = await agent.execute({ data: makeInput() }); const check = result.data.result.checks.find((c) => c.check_id === 'COMMISSION_RATE'); expect(check!.passed).toBe(true); - expect(check!.reason).toContain('skipped'); + expect(check!.reason).toMatch(/skipped/i); + expect(check!.reason).toMatch(/no embedded carrier commission tables/i); }); }); - describe('Check 7: Endorsement box', () => { + describe('Check 8: Endorsement box', () => { it('warns when restricted fare has no endorsement', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'HOWUS' }) }); const check = result.data.result.checks.find((c) => c.check_id === 'ENDORSEMENT_BOX'); @@ -268,7 +445,7 @@ describe('ADM Prevention', () => { }); }); - describe('Check 8: Tour code format', () => { + describe('Check 9: Tour code format', () => { it('passes with valid tour code', async () => { const result = await agent.execute({ data: makeInput({ tour_code: 'BT123ABC' }) }); const check = result.data.result.checks.find((c) => c.check_id === 'TOUR_CODE_FORMAT'); @@ -295,7 +472,7 @@ describe('ADM Prevention', () => { }); }); - describe('Check 9: Net remit', () => { + describe('Check 10: Net remit', () => { it('passes when base fare within net contracted', async () => { const input = makeInput({ is_net_remit: true, net_contracted_amount: '500.00' }); const result = await agent.execute({ data: input }); @@ -325,6 +502,47 @@ describe('ADM Prevention', () => { }); }); + describe('Scary false-negative fixtures', () => { + const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith('.json')); + + it('loads all five scary fixtures', () => { + expect(files.sort()).toEqual( + [ + 'churn-all-hk-now.json', + 'ttl-deadline-day-tz.json', + 'travelport-dx-marriage.json', + 'uc-hn-passive-pk.json', + 'uncleared-tk.json', + ].sort(), + ); + }); + + for (const file of files) { + it(`encodes fixture ${file}`, async () => { + const raw = JSON.parse(readFileSync(join(FIXTURES_DIR, file), 'utf8')) as Record< + string, + unknown + >; + const expectMap = raw['expect'] as Record; + const { expect: _e, _comment: _c, ...inputFields } = raw; + const result = await agent.execute({ + data: inputFields as unknown as ADMPreventionInput, + }); + + expect(result.data.result.overall_pass).toBe(expectMap['overall_pass']); + + for (const [checkId, passed] of Object.entries(expectMap)) { + if (checkId === 'overall_pass') continue; + const check = result.data.result.checks.find( + (c) => c.check_id === (checkId as ADMCheckId), + ); + expect(check, `missing check ${checkId}`).toBeDefined(); + expect(check!.passed).toBe(passed); + } + }); + } + }); + describe('Overall result', () => { it('passes when all checks pass', async () => { const input = makeInput({ @@ -355,9 +573,10 @@ describe('ADM Prevention', () => { expect(result.data.result.warning_count).toBeGreaterThan(0); }); - it('runs all 9 checks', async () => { + it(`runs all ${ADM_CHECK_COUNT} checks`, async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.result.checks).toHaveLength(9); + expect(result.data.result.checks).toHaveLength(ADM_CHECK_COUNT); + expect(ADM_CHECK_COUNT).toBe(10); }); }); @@ -403,7 +622,7 @@ describe('ADM Prevention', () => { it('returns metadata in output', async () => { const result = await agent.execute({ data: makeInput() }); expect(result.metadata!['agent_id']).toBe('6.2'); - expect(result.metadata!['checks_run']).toBe(9); + expect(result.metadata!['checks_run']).toBe(ADM_CHECK_COUNT); }); it('warns on blocking issues', async () => { diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/churn-all-hk-now.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/churn-all-hk-now.json new file mode 100644 index 0000000..b01dee2 --- /dev/null +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/churn-all-hk-now.json @@ -0,0 +1,88 @@ +{ + "_comment": "SCARY FALSE NEGATIVE: all current statuses HK — HX/UN/NO-only scanners pass. History shows 3 cancel→rebook cycles (churning).", + "gds": "AMADEUS", + "current_datetime": "2026-04-01T12:00:00Z", + "fare_basis": "HOWUS", + "booked_class": "H", + "endorsement": "NON-ENDO/NON-REF", + "booking": { + "record_locator": "CHURN1", + "passenger_name": "SMITH/JOHN", + "base_fare": "450.00", + "base_fare_currency": "USD", + "segments": [ + { + "carrier": "BA", + "flight_number": "115", + "origin": "LHR", + "destination": "JFK", + "departure_date": "2026-06-15", + "status": "HK", + "booking_class": "H" + } + ] + }, + "segment_history": [ + { + "timestamp": "2026-03-28T08:00:00Z", + "action": "BOOKED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "HK" + }, + { + "timestamp": "2026-03-28T10:00:00Z", + "action": "CANCELLED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "XX" + }, + { + "timestamp": "2026-03-28T10:05:00Z", + "action": "REBOOKED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "HK" + }, + { + "timestamp": "2026-03-29T09:00:00Z", + "action": "CANCELLED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "XX" + }, + { + "timestamp": "2026-03-29T09:10:00Z", + "action": "REBOOKED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "HK" + }, + { + "timestamp": "2026-03-30T11:00:00Z", + "action": "CANCELLED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "XX" + }, + { + "timestamp": "2026-03-30T11:15:00Z", + "action": "REBOOKED", + "carrier": "BA", + "flight_number": "115", + "departure_date": "2026-06-15", + "status": "HK" + } + ], + "expect": { + "CHURNING": false, + "PASSIVE_SEGMENT": true, + "overall_pass": false + } +} diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/travelport-dx-marriage.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/travelport-dx-marriage.json new file mode 100644 index 0000000..4a7f757 --- /dev/null +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/travelport-dx-marriage.json @@ -0,0 +1,41 @@ +{ + "_comment": "SCARY FALSE NEGATIVE: Travelport DX broken-marriage signal. Status alone — no ticketing field required.", + "gds": "TRAVELPORT", + "current_datetime": "2026-04-01T12:00:00Z", + "fare_basis": "HOWUS", + "booked_class": "H", + "endorsement": "NON-ENDO/NON-REF", + "booking": { + "record_locator": "DXMARR", + "passenger_name": "SMITH/JOHN", + "base_fare": "450.00", + "base_fare_currency": "USD", + "segments": [ + { + "carrier": "UA", + "flight_number": "900", + "origin": "ORD", + "destination": "LHR", + "departure_date": "2026-09-01", + "status": "DX", + "booking_class": "H", + "married_group": "M1" + }, + { + "carrier": "UA", + "flight_number": "901", + "origin": "LHR", + "destination": "FRA", + "departure_date": "2026-09-01", + "status": "HK", + "booking_class": "H", + "married_group": "M1" + } + ] + }, + "expect": { + "MARRIED_SEGMENT": false, + "PASSIVE_SEGMENT": false, + "overall_pass": false + } +} diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/ttl-deadline-day-tz.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/ttl-deadline-day-tz.json new file mode 100644 index 0000000..460e18a --- /dev/null +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/ttl-deadline-day-tz.json @@ -0,0 +1,32 @@ +{ + "_comment": "SCARY FALSE NEGATIVE: UTC still has ~8h until deadline, but local calendar date in America/New_York equals deadline day — same-day-of-deadline ADM risk.", + "gds": "SABRE", + "current_datetime": "2026-04-01T16:00:00Z", + "ttl_deadline": "2026-04-02T00:00:00Z", + "ttl_timezone": "America/New_York", + "ttl_source": "BOOKING", + "fare_basis": "HOWUS", + "booked_class": "H", + "endorsement": "NON-ENDO/NON-REF", + "booking": { + "record_locator": "TTLDAY", + "passenger_name": "SMITH/JOHN", + "base_fare": "450.00", + "base_fare_currency": "USD", + "segments": [ + { + "carrier": "BA", + "flight_number": "115", + "origin": "LHR", + "destination": "JFK", + "departure_date": "2026-06-15", + "status": "HK", + "booking_class": "H" + } + ] + }, + "expect": { + "TTL_EXPIRED": false, + "overall_pass": false + } +} diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json new file mode 100644 index 0000000..63a83a6 --- /dev/null +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json @@ -0,0 +1,47 @@ +{ + "_comment": "SCARY FALSE NEGATIVE: UC + HN + PK — beyond HX/UN/NO. Passive entry (PK) plus unable (UC) and pending need (HN).", + "gds": "AMADEUS", + "current_datetime": "2026-04-01T12:00:00Z", + "fare_basis": "HOWUS", + "booked_class": "H", + "endorsement": "NON-ENDO/NON-REF", + "booking": { + "record_locator": "UCPK01", + "passenger_name": "SMITH/JOHN", + "base_fare": "450.00", + "base_fare_currency": "USD", + "segments": [ + { + "carrier": "AF", + "flight_number": "10", + "origin": "CDG", + "destination": "JFK", + "departure_date": "2026-08-01", + "status": "UC", + "booking_class": "H" + }, + { + "carrier": "AF", + "flight_number": "11", + "origin": "JFK", + "destination": "CDG", + "departure_date": "2026-08-15", + "status": "HN", + "booking_class": "H" + }, + { + "carrier": "AF", + "flight_number": "12", + "origin": "CDG", + "destination": "LYS", + "departure_date": "2026-08-16", + "status": "PK", + "booking_class": "H" + } + ] + }, + "expect": { + "PASSIVE_SEGMENT": false, + "overall_pass": false + } +} diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uncleared-tk.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uncleared-tk.json new file mode 100644 index 0000000..9abb4a6 --- /dev/null +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uncleared-tk.json @@ -0,0 +1,29 @@ +{ + "_comment": "SCARY FALSE NEGATIVE: uncleared TK schedule-change — not in classic HX/UN/NO-only sets. Core host set is HX/UC/UN/NO/TK.", + "gds": "SABRE", + "current_datetime": "2026-04-01T12:00:00Z", + "fare_basis": "HOWUS", + "booked_class": "H", + "endorsement": "NON-ENDO/NON-REF", + "booking": { + "record_locator": "TKONLY", + "passenger_name": "SMITH/JOHN", + "base_fare": "450.00", + "base_fare_currency": "USD", + "segments": [ + { + "carrier": "AA", + "flight_number": "100", + "origin": "DFW", + "destination": "LHR", + "departure_date": "2026-07-01", + "status": "TK", + "booking_class": "H" + } + ] + }, + "expect": { + "PASSIVE_SEGMENT": false, + "overall_pass": false + } +} diff --git a/packages/agents/settlement/src/adm-prevention/audit-engine.ts b/packages/agents/settlement/src/adm-prevention/audit-engine.ts index e01d144..e6f2622 100644 --- a/packages/agents/settlement/src/adm-prevention/audit-engine.ts +++ b/packages/agents/settlement/src/adm-prevention/audit-engine.ts @@ -1,5 +1,12 @@ /** - * ADM Prevention Engine — 9 pre-ticketing audit checks. + * ADM Prevention Engine — pre-ticketing audit checks. + * + * Domain source: docs/knowledge-base/adm-prevention.md + * - IATA Reso 850m = ADM memo windows/dispute (Agent 6.3), not status rules + * - Passive/UC/churn = carrier booking policy + host statuses (HX/UC/UN/NO/TK + extended) + * - Travelport: those statuses do not need a ticketing field + * - Churning requires segment history + * - No carrier-secret commission tables */ import Decimal from 'decimal.js'; @@ -8,7 +15,15 @@ import type { ADMPreventionOutput, ADMPreventionResult, ADMCheck, + SegmentHistoryEvent, } from './types.js'; +import { + isBlockingSegmentStatus, + isCoreBlockingStatus, + isTravelportMarriageBreakStatus, + DEFAULT_CHURN_CYCLE_THRESHOLD, + DEFAULT_CHURN_WINDOW_HOURS, +} from './status-codes.js'; // Fare basis first-character to expected booking class mapping // This is a simplified mapping — real ATPCO mappings are far more complex @@ -49,6 +64,29 @@ function currentTime(input: ADMPreventionInput): Date { return input.current_datetime ? new Date(input.current_datetime) : new Date(); } +function flightKey(carrier: string, flight: string, date: string): string { + return `${carrier.toUpperCase()}|${flight}|${date}`; +} + +/** + * Calendar date (YYYY-MM-DD) in an IANA timezone for an instant. + * Used for deadline-day ADM risk — not a substitute for carrier TTL rules. + */ +function localCalendarDate(isoInstant: string, timeZone: string): string | null { + try { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + return fmt.format(new Date(isoInstant)); + } catch { + // TODO: DOMAIN_QUESTION: invalid ttl_timezone handling — fail open vs block + return null; + } +} + // --------------------------------------------------------------------------- // Individual checks // --------------------------------------------------------------------------- @@ -108,20 +146,98 @@ function checkFareClassMismatch(input: ADMPreventionInput): ADMCheck { return check; } +/** + * Passive / unable / schedule-change / pending statuses. + * Core set HX/UC/UN/NO/TK from carrier booking policy + host practice. + * Travelport: status alone is enough — no ticketing field required. + */ function checkPassiveSegments(input: ADMPreventionInput): ADMCheck { const check: ADMCheck = { check_id: 'PASSIVE_SEGMENT', - name: 'Passive Segment Abuse', + name: 'Passive / Unable / Risky Status', severity: 'blocking', passed: true, - reason: 'No passive segments found.', + reason: 'No passive, unable, or uncleared risky statuses found.', }; - const passiveStatuses = new Set(['HX', 'UN', 'NO', 'UC']); for (const seg of input.booking.segments) { - if (passiveStatuses.has(seg.status)) { + if (!isBlockingSegmentStatus(seg.status)) continue; + + const code = seg.status.toUpperCase(); + const core = isCoreBlockingStatus(code); + const travelportNote = + input.gds === 'TRAVELPORT' + ? ' Travelport: status alone is sufficient (no ticketing field required).' + : ''; + + check.passed = false; + check.reason = core + ? `Risky host status: ${seg.carrier}${seg.flight_number} status ${code} (core set HX/UC/UN/NO/TK) — must be cleared before ticketing.${travelportNote}` + : `Risky host status: ${seg.carrier}${seg.flight_number} status ${code} — passive/pending/cancel residue must be removed before ticketing.${travelportNote}`; + return check; + } + + return check; +} + +/** + * Churning: book→cancel→rebook cycles. Requires segment_history. + * Current HK-only status is a classic false negative without history. + */ +function checkChurning(input: ADMPreventionInput): ADMCheck { + const check: ADMCheck = { + check_id: 'CHURNING', + name: 'Churning Detection', + severity: 'blocking', + passed: true, + reason: 'No churning pattern detected.', + }; + + const history = input.segment_history; + if (!history || history.length === 0) { + check.reason = + 'No segment history provided — churning skipped (cannot detect from current status alone).'; + return check; + } + + const threshold = input.churn_cycle_threshold ?? DEFAULT_CHURN_CYCLE_THRESHOLD; + const windowHours = input.churn_window_hours ?? DEFAULT_CHURN_WINDOW_HOURS; + const windowMs = windowHours * 60 * 60 * 1000; + + const byFlight = new Map(); + for (const event of history) { + const key = flightKey(event.carrier, event.flight_number, event.departure_date); + const list = byFlight.get(key) ?? []; + list.push(event); + byFlight.set(key, list); + } + + for (const [key, events] of byFlight) { + const sorted = [...events].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + let cycles = 0; + let lastCancelAt: number | null = null; + + for (const event of sorted) { + const t = new Date(event.timestamp).getTime(); + if (event.action === 'CANCELLED') { + lastCancelAt = t; + } else if ( + (event.action === 'REBOOKED' || event.action === 'BOOKED') && + lastCancelAt != null && + t - lastCancelAt <= windowMs && + t >= lastCancelAt + ) { + cycles += 1; + lastCancelAt = null; + } + } + + if (cycles >= threshold) { check.passed = false; - check.reason = `Passive segment: ${seg.carrier}${seg.flight_number} status ${seg.status} — must be removed before ticketing.`; + check.reason = `Churning: ${cycles} cancel→rebook cycles on ${key.replace(/\|/g, ' ')} within ${windowHours}h (threshold ${threshold}). Current status alone is insufficient — history required.`; return check; } } @@ -138,20 +254,44 @@ function checkMarriedSegments(input: ADMPreventionInput): ADMCheck { reason: 'Married segments are consistent.', }; - const groups = new Map(); + // Travelport-specific: DX is a broken-marriage / marriage-integrity signal + // (public Travelport status table). No separate ticketing field required. + if (input.gds === 'TRAVELPORT') { + for (const seg of input.booking.segments) { + if (isTravelportMarriageBreakStatus(seg.status)) { + check.passed = false; + check.reason = `Travelport DX on ${seg.carrier}${seg.flight_number}: broken marriage / marriage-integrity risk — do not ticket until marriage is restored or properly authorized.`; + return check; + } + } + } + + const groups = new Map(); for (const seg of input.booking.segments) { if (seg.married_group) { - const statuses = groups.get(seg.married_group) ?? []; - statuses.push(seg.status); - groups.set(seg.married_group, statuses); + const list = groups.get(seg.married_group) ?? []; + list.push(seg); + groups.set(seg.married_group, list); } } - for (const [group, statuses] of groups) { - const unique = new Set(statuses); - if (unique.size > 1) { + if (groups.size === 0) { + check.reason = + 'No married_group markers on segments — skipped. (Sabre MSI / Amadeus marriage / Travelport group must be mapped by the adapter.)'; + return check; + } + + for (const [group, segs] of groups) { + if (segs.length < 2) { check.passed = false; - check.reason = `Married group ${group} has mixed statuses: ${[...unique].join(', ')} — must be identical.`; + check.reason = `Married group ${group} has only ${segs.length} segment — incomplete marriage (possible break).`; + return check; + } + + const statuses = new Set(segs.map((s) => s.status.toUpperCase())); + if (statuses.size > 1) { + check.passed = false; + check.reason = `Married group ${group} has mixed statuses: ${[...statuses].join(', ')} — must be identical (GDS marriage integrity).`; return check; } } @@ -176,13 +316,29 @@ function checkTtlExpired(input: ADMPreventionInput): ADMCheck { const now = currentTime(input); const deadline = new Date(input.ttl_deadline); const minutesRemaining = (deadline.getTime() - now.getTime()) / (1000 * 60); + const sourceNote = input.ttl_source ? ` (TTL source: ${input.ttl_source})` : ''; if (minutesRemaining < 0) { check.passed = false; - check.reason = `TTL expired at ${input.ttl_deadline} — cannot ticket.`; - } else if (minutesRemaining < TTL_BUFFER_MINUTES) { + check.reason = `TTL expired at ${input.ttl_deadline}${sourceNote} — cannot ticket.`; + return check; + } + + // Deadline-day risk: same local calendar date as deadline is ADM-prone + // even when UTC still shows remaining hours. + if (input.ttl_timezone && input.current_datetime) { + const nowLocal = localCalendarDate(input.current_datetime, input.ttl_timezone); + const deadlineLocal = localCalendarDate(input.ttl_deadline, input.ttl_timezone); + if (nowLocal && deadlineLocal && nowLocal === deadlineLocal) { + check.passed = false; + check.reason = `TTL deadline-day risk: current local date ${nowLocal} in ${input.ttl_timezone} equals deadline date — carriers commonly ADM same-day-of-deadline issuance${sourceNote}.`; + return check; + } + } + + if (minutesRemaining < TTL_BUFFER_MINUTES) { check.passed = false; - check.reason = `TTL expires in ${Math.round(minutesRemaining)} minutes (< ${TTL_BUFFER_MINUTES}min buffer) — risk of expiry during ticketing.`; + check.reason = `TTL expires in ${Math.round(minutesRemaining)} minutes (< ${TTL_BUFFER_MINUTES}min buffer) — risk of expiry during ticketing${sourceNote}.`; } return check; @@ -197,8 +353,10 @@ function checkCommissionRate(input: ADMPreventionInput): ADMCheck { reason: 'Commission rate is within contracted limits.', }; + // No carrier-secret commission tables in-repo — caller must supply both rates. if (input.commission_rate == null || input.carrier_contracted_rate == null) { - check.reason = 'Commission rate or contracted rate not provided — skipped.'; + check.reason = + 'Commission rate or contracted rate not provided — skipped (no embedded carrier commission tables).'; return check; } @@ -287,11 +445,15 @@ function checkNetRemit(input: ADMPreventionInput): ADMCheck { // Main engine // --------------------------------------------------------------------------- +/** Number of audit checks run by Agent 6.2 (includes CHURNING). */ +export const ADM_CHECK_COUNT = 10; + export function runAudit(input: ADMPreventionInput): ADMPreventionOutput { const checks: ADMCheck[] = [ checkDuplicateBooking(input), checkFareClassMismatch(input), checkPassiveSegments(input), + checkChurning(input), checkMarriedSegments(input), checkTtlExpired(input), checkCommissionRate(input), diff --git a/packages/agents/settlement/src/adm-prevention/index.ts b/packages/agents/settlement/src/adm-prevention/index.ts index c641e5d..5555269 100644 --- a/packages/agents/settlement/src/adm-prevention/index.ts +++ b/packages/agents/settlement/src/adm-prevention/index.ts @@ -1,9 +1,10 @@ /** * ADM Prevention — Agent 6.2 * - * Pre-ticketing audit: 9 checks covering fare integrity, - * segment validity, and compliance to prevent Agency Debit Memos. + * Pre-ticketing audit covering fare integrity, passive/UC/churning, + * married-segment integrity, TTL, and compliance to prevent ADMs. * + * Domain: docs/knowledge-base/adm-prevention.md * Implements the base Agent interface from @otaip/core. */ @@ -19,7 +20,7 @@ const CLASS_RE = /^[A-Z]$/; export class ADMPrevention implements Agent { readonly id = '6.2'; readonly name = 'ADM Prevention'; - readonly version = '0.1.0'; + readonly version = '0.2.0'; private initialized = false; @@ -107,6 +108,15 @@ export class ADMPrevention implements Agent(CORE_BLOCKING_STATUSES); +const EXTENDED_SET = new Set(EXTENDED_BLOCKING_STATUSES); + +/** All statuses that must be cleared before ticketing. */ +export function isBlockingSegmentStatus(status: string): boolean { + const code = status.toUpperCase(); + return CORE_SET.has(code) || EXTENDED_SET.has(code); +} + +export function isCoreBlockingStatus(status: string): boolean { + return CORE_SET.has(status.toUpperCase()); +} + +/** Travelport: DX indicates broken marriage / marriage integrity risk. */ +export function isTravelportMarriageBreakStatus(status: string): boolean { + return status.toUpperCase() === 'DX'; +} + +export type GdsHost = 'SABRE' | 'AMADEUS' | 'TRAVELPORT' | 'UNKNOWN'; + +/** Default churn heuristic — overridable; not a carrier-secret table. */ +export const DEFAULT_CHURN_CYCLE_THRESHOLD = 3; +export const DEFAULT_CHURN_WINDOW_HOURS = 72; diff --git a/packages/agents/settlement/src/adm-prevention/types.ts b/packages/agents/settlement/src/adm-prevention/types.ts index 2d540cf..1692210 100644 --- a/packages/agents/settlement/src/adm-prevention/types.ts +++ b/packages/agents/settlement/src/adm-prevention/types.ts @@ -2,13 +2,21 @@ * ADM Prevention — Types * * Agent 6.2: Pre-ticketing audit to prevent Agency Debit Memos. - * Nine checks covering fare integrity, segment validity, and compliance. + * Checks cover fare integrity, segment validity (passive/UC/churn), + * married integrity, TTL, and compliance. + * + * Domain source: docs/knowledge-base/adm-prevention.md */ +import type { GdsHost } from './status-codes.js'; + +export type { GdsHost }; + export type ADMCheckId = | 'DUPLICATE_BOOKING' | 'FARE_CLASS_MISMATCH' | 'PASSIVE_SEGMENT' + | 'CHURNING' | 'MARRIED_SEGMENT' | 'TTL_EXPIRED' | 'COMMISSION_RATE' @@ -42,7 +50,7 @@ export interface BookingSegment { destination: string; /** Departure date (ISO) */ departure_date: string; - /** Segment status (HK, KK, SS, HX, UN, etc.) */ + /** Segment status (HK, KK, HX, UC, UN, NO, TK, HN, PK, …) */ status: string; /** Booked class */ booking_class: string; @@ -50,6 +58,31 @@ export interface BookingSegment { married_group?: string; } +/** + * Historical segment event — required for churning detection. + * Current status alone cannot prove or disprove churn. + */ +export type SegmentHistoryAction = 'BOOKED' | 'CANCELLED' | 'REBOOKED'; + +export interface SegmentHistoryEvent { + /** ISO timestamp of the event */ + timestamp: string; + /** What happened */ + action: SegmentHistoryAction; + /** Carrier */ + carrier: string; + /** Flight number */ + flight_number: string; + /** Departure date (ISO date) */ + departure_date: string; + /** Origin (optional — strengthens identity match) */ + origin?: string; + /** Destination (optional) */ + destination?: string; + /** Status after the event, if known */ + status?: string; +} + export interface DuplicateCheckPnr { /** Record locator */ record_locator: string; @@ -76,6 +109,9 @@ export interface BookingRecord { base_fare_currency: string; } +/** Where the TTL value came from — affects messaging, not invented rules. */ +export type TtlSource = 'BOOKING' | 'FARE_QUOTE' | 'CARRIER_RULE' | 'UNKNOWN'; + export interface ADMPreventionInput { /** Booking record to audit */ booking: BookingRecord; @@ -83,9 +119,12 @@ export interface ADMPreventionInput { fare_basis: string; /** Booked class (single letter) */ booked_class: string; - /** Commission rate on this ticket (percentage, e.g. 7.0) */ + /** + * Commission rate on this ticket (percentage, e.g. 7.0). + * Compared only to caller-supplied contracted rate — no embedded carrier tables. + */ commission_rate?: number; - /** Carrier's contracted commission rate (percentage) */ + /** Carrier's contracted commission rate (percentage) — supplied by caller */ carrier_contracted_rate?: number; /** Endorsement text on ticket */ endorsement?: string; @@ -97,10 +136,25 @@ export interface ADMPreventionInput { net_contracted_amount?: string; /** TTL deadline (ISO timestamp) */ ttl_deadline?: string; + /** IANA timezone for deadline-day evaluation (e.g. America/New_York) */ + ttl_timezone?: string; + /** How TTL was established */ + ttl_source?: TtlSource; /** Other PNRs to check for duplicates */ duplicate_check_pnrs?: DuplicateCheckPnr[]; /** Current date/time (ISO — for TTL check) */ current_datetime?: string; + /** + * Ordered segment history for churning detection. + * Without this, CHURNING is skipped (not assumed clear). + */ + segment_history?: SegmentHistoryEvent[]; + /** Host GDS — used for marriage-break signals (e.g. Travelport DX) */ + gds?: GdsHost; + /** Override default churn cycle threshold (default 3) */ + churn_cycle_threshold?: number; + /** Override default churn window in hours (default 72) */ + churn_window_hours?: number; } export interface ADMPreventionResult { diff --git a/packages/agents/settlement/src/index.ts b/packages/agents/settlement/src/index.ts index d977c5a..48e99a9 100644 --- a/packages/agents/settlement/src/index.ts +++ b/packages/agents/settlement/src/index.ts @@ -21,7 +21,15 @@ export type { RefundPenaltyRule, } from './refund-processing/index.js'; -export { ADMPrevention } from './adm-prevention/index.js'; +export { + ADMPrevention, + ADM_CHECK_COUNT, + CORE_BLOCKING_STATUSES, + EXTENDED_BLOCKING_STATUSES, + isBlockingSegmentStatus, + DEFAULT_CHURN_CYCLE_THRESHOLD, + DEFAULT_CHURN_WINDOW_HOURS, +} from './adm-prevention/index.js'; export type { ADMPreventionInput, ADMPreventionOutput, @@ -32,6 +40,10 @@ export type { BookingRecord, BookingSegment, DuplicateCheckPnr, + SegmentHistoryEvent, + SegmentHistoryAction, + TtlSource, + GdsHost, } from './adm-prevention/index.js'; export { ADMACMProcessingAgent } from './adm-acm-processing/index.js'; From 9bb470a3c64bec3155c2f1ae1ee82e5a68f4b094 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:05:00 +0000 Subject: [PATCH 2/5] fix(adm-prevention): keep HN/PK/GK/YK in per-host maps Do not globalize host-specific status codes as one IATA meaning. Core HX/UC/UN/NO/TK stays cross-host; HN/PK/GK/YK and peers apply only when gds is set. Unknown host uses core only (DOMAIN_QUESTION). Co-authored-by: telivity-otaip --- docs/knowledge-base/adm-prevention.md | 34 ++--- .../__tests__/adm-prevention.test.ts | 48 ++++++- .../__tests__/fixtures/uc-hn-passive-pk.json | 2 +- .../src/adm-prevention/audit-engine.ts | 21 +-- .../settlement/src/adm-prevention/index.ts | 3 +- .../src/adm-prevention/status-codes.ts | 120 +++++++++++++----- .../settlement/src/adm-prevention/types.ts | 2 +- packages/agents/settlement/src/index.ts | 3 +- 8 files changed, 168 insertions(+), 65 deletions(-) diff --git a/docs/knowledge-base/adm-prevention.md b/docs/knowledge-base/adm-prevention.md index 4a855d2..6cabd96 100644 --- a/docs/knowledge-base/adm-prevention.md +++ b/docs/knowledge-base/adm-prevention.md @@ -25,22 +25,22 @@ Carrier booking policies and host queues treat these **advice / status** codes a **Travelport note:** On Travelport hosts, the statuses above are sufficient for detection — they do **not** require an additional ticketing-specific field on the segment. Status alone drives the passive/unable/schedule-change queue action. -### Also flag before ticketing (extended matrix) - -Beyond the core set, public GDS docs list codes that are passive-entry, pending, or otherwise unsafe to ticket. Agent 6.2 treats these as blocking when present on air segments: - -| Code | Role | Notes (public docs) | -| --------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| **HN** | Holding need / pending need | Not confirmed — CLAUDE Agent 6.2 guard: must check HN, not only HX/UN/NO | -| **PK / PL** | Passive confirmed / waitlisted (Amadeus) | Passive sell — airline may reply NO/UC/UN/HX | -| **GK / GL / GN** | Ghost / guaranteed passive-style | Amadeus ghost; Sabre/Travelport meanings vary by host | -| **YK** | Sabre administrative / itinerary passive (common ops usage) | Often used for invoice/itinerary, not live sell | -| **AK / AL / AN** | Travelport Galileo passive outside system | Confirmed / waitlisted / requested outside 1G | -| **BK / BL** | Travelport passive booked / waitlist with carrier | Cancel may message carrier | -| **MK / PS / ZK / LK** | Travelport passive / non-messaging / API / link | Host-specific passive families | -| **DX** | Travelport: passive broken marriage **or** authorized partial cancel in marriage | Married-integrity signal — see below | -| **UU / US** | Unable, waitlisted / unable to sell | Not sellable confirmed space | -| **XX / XK** | Cancel segment / cancel with change | Residual cancel statuses must be cleaned | +### Per-host extended codes (NOT one IATA meaning) + +**Do not globalize** HN / PK / GK / YK (or other extended codes) as a single industry-wide meaning. The same two-letter string can mean different things on Sabre vs Amadeus vs Travelport. Agent 6.2 applies these **only when `gds` is set** to that host. If `gds` is omitted / `UNKNOWN`, only the core set above applies. + +| Host | Codes treated as blocking (public host tables / ops) | Notes | +| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ | +| **Amadeus** | HN, PK, PL, GK, GL, GN, UU, US, XX | PK/PL = passive sell; GK/GL/GN = ghost — Amadeus semantics | +| **Sabre** | YK, GK, HN, XX | YK = Sabre administrative/itinerary passive; GK ≠ Amadeus ghost | +| **Travelport** | HN, AK, AL, AN, BK, BL, DX, MK, PS, ZK, LK, UU, US, XX, XK | DX = broken marriage / marriage-integrity signal (Travelport-only) | + +``` +// TODO: DOMAIN_QUESTION: Confirm Sabre vs Amadeus GK semantics in each adapter's +// normalized status before treating GK as interchangeable across hosts. +// TODO: DOMAIN_QUESTION: When gds is UNKNOWN, host-specific codes are ignored +// (core only). Should UNKNOWN emit a warning instead of silent skip? +``` Active / ticketable examples (not exhaustive): **HK**, **KK**, **KL**, **RR**, **HS** (context-dependent). Do not treat “looks confirmed now” as proof the PNR was never churned — see churning. @@ -109,7 +109,7 @@ See `packages/agents/settlement/src/adm-prevention/__tests__/fixtures/`: | ----------------------------- | --------------------------------------------------------------------------- | | `churn-all-hk-now.json` | Current statuses all HK — HX/UN/NO-only scanners pass; history proves churn | | `uncleared-tk.json` | TK schedule-change left on PNR — not in classic HX/UN/NO set | -| `uc-hn-passive-pk.json` | UC + HN + PK together — beyond HX/UN/NO | +| `uc-hn-passive-pk.json` | Amadeus UC + HN + PK — host-specific HN/PK, not a universal IATA meaning | | `travelport-dx-marriage.json` | Travelport DX / broken marriage without needing a ticketing field | | `ttl-deadline-day-tz.json` | UTC still “has time” but local deadline day has begun | diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts b/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts index 7ad06f5..85ffd08 100644 --- a/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts +++ b/packages/agents/settlement/src/adm-prevention/__tests__/adm-prevention.test.ts @@ -180,22 +180,66 @@ describe('ADM Prevention', () => { } }); - it('fails with HN pending need', async () => { + it('fails with Amadeus HN pending need when gds is AMADEUS', async () => { const input = makeInput({ + gds: 'AMADEUS', booking: makeBooking({ segments: [makeSegment({ status: 'HN' })] }), }); const result = await agent.execute({ data: input }); const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); expect(check!.passed).toBe(false); + expect(check!.reason).toMatch(/AMADEUS/i); }); - it('fails with Amadeus passive PK', async () => { + it('does not treat HN as universal when gds is omitted', async () => { const input = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status: 'HN' })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(true); + }); + + it('fails with Amadeus passive PK when gds is AMADEUS', async () => { + const input = makeInput({ + gds: 'AMADEUS', booking: makeBooking({ segments: [makeSegment({ status: 'PK' })] }), }); const result = await agent.execute({ data: input }); const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); expect(check!.passed).toBe(false); + expect(check!.reason).toMatch(/AMADEUS/i); + }); + + it('does not treat PK/GK/YK as universal when gds is omitted', async () => { + for (const status of ['PK', 'GK', 'YK'] as const) { + const input = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status })] }), + }); + const result = await agent.execute({ data: input }); + const check = result.data.result.checks.find((c) => c.check_id === 'PASSIVE_SEGMENT'); + expect(check!.passed).toBe(true); + } + }); + + it('fails with Sabre YK only when gds is SABRE', async () => { + const withoutHost = makeInput({ + booking: makeBooking({ segments: [makeSegment({ status: 'YK' })] }), + }); + const withSabre = makeInput({ + gds: 'SABRE', + booking: makeBooking({ segments: [makeSegment({ status: 'YK' })] }), + }); + expect( + (await agent.execute({ data: withoutHost })).data.result.checks.find( + (c) => c.check_id === 'PASSIVE_SEGMENT', + )!.passed, + ).toBe(true); + expect( + (await agent.execute({ data: withSabre })).data.result.checks.find( + (c) => c.check_id === 'PASSIVE_SEGMENT', + )!.passed, + ).toBe(false); }); it('notes Travelport needs no ticketing field', async () => { diff --git a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json index 63a83a6..f0173b7 100644 --- a/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json +++ b/packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json @@ -1,5 +1,5 @@ { - "_comment": "SCARY FALSE NEGATIVE: UC + HN + PK — beyond HX/UN/NO. Passive entry (PK) plus unable (UC) and pending need (HN).", + "_comment": "SCARY FALSE NEGATIVE: Amadeus UC + HN + PK — beyond HX/UN/NO. HN/PK are Amadeus host-specific (not a universal IATA meaning).", "gds": "AMADEUS", "current_datetime": "2026-04-01T12:00:00Z", "fare_basis": "HOWUS", diff --git a/packages/agents/settlement/src/adm-prevention/audit-engine.ts b/packages/agents/settlement/src/adm-prevention/audit-engine.ts index e6f2622..b9af008 100644 --- a/packages/agents/settlement/src/adm-prevention/audit-engine.ts +++ b/packages/agents/settlement/src/adm-prevention/audit-engine.ts @@ -19,7 +19,6 @@ import type { } from './types.js'; import { isBlockingSegmentStatus, - isCoreBlockingStatus, isTravelportMarriageBreakStatus, DEFAULT_CHURN_CYCLE_THRESHOLD, DEFAULT_CHURN_WINDOW_HOURS, @@ -148,8 +147,10 @@ function checkFareClassMismatch(input: ADMPreventionInput): ADMCheck { /** * Passive / unable / schedule-change / pending statuses. - * Core set HX/UC/UN/NO/TK from carrier booking policy + host practice. - * Travelport: status alone is enough — no ticketing field required. + * Core set HX/UC/UN/NO/TK applies cross-host. + * HN/PK/GK/YK and other extended codes apply only via per-host maps + * (not one universal IATA meaning). Travelport: status alone is enough — + * no ticketing field required. */ function checkPassiveSegments(input: ADMPreventionInput): ADMCheck { const check: ADMCheck = { @@ -161,19 +162,21 @@ function checkPassiveSegments(input: ADMPreventionInput): ADMCheck { }; for (const seg of input.booking.segments) { - if (!isBlockingSegmentStatus(seg.status)) continue; + const verdict = isBlockingSegmentStatus(seg.status, input.gds); + if (!verdict.blocking) continue; const code = seg.status.toUpperCase(); - const core = isCoreBlockingStatus(code); const travelportNote = - input.gds === 'TRAVELPORT' + verdict.host === 'TRAVELPORT' ? ' Travelport: status alone is sufficient (no ticketing field required).' : ''; check.passed = false; - check.reason = core - ? `Risky host status: ${seg.carrier}${seg.flight_number} status ${code} (core set HX/UC/UN/NO/TK) — must be cleared before ticketing.${travelportNote}` - : `Risky host status: ${seg.carrier}${seg.flight_number} status ${code} — passive/pending/cancel residue must be removed before ticketing.${travelportNote}`; + if (verdict.scope === 'core') { + check.reason = `Risky host status: ${seg.carrier}${seg.flight_number} status ${code} (core set HX/UC/UN/NO/TK) — must be cleared before ticketing.${travelportNote}`; + } else { + check.reason = `Risky ${verdict.host} host status: ${seg.carrier}${seg.flight_number} status ${code} — host-specific passive/pending/cancel residue (not a universal IATA code meaning) — must be removed before ticketing.${travelportNote}`; + } return check; } diff --git a/packages/agents/settlement/src/adm-prevention/index.ts b/packages/agents/settlement/src/adm-prevention/index.ts index 5555269..916a024 100644 --- a/packages/agents/settlement/src/adm-prevention/index.ts +++ b/packages/agents/settlement/src/adm-prevention/index.ts @@ -111,8 +111,9 @@ export class ADMPrevention implements Agent> = { + AMADEUS: [ + 'HN', // Holding need (Amadeus status tables) + 'PK', // Passive confirmed + 'PL', // Passive waitlisted + 'GK', // Confirmed ghost segment (Amadeus) + 'GL', // Waitlisted ghost + 'GN', // Ghost need + 'UU', // Unable, have waitlisted + 'US', // Unable to accept sale, have waitlisted + 'XX', // Cancel residue + ], + SABRE: [ + 'YK', // Sabre administrative / itinerary passive (ops usage — not Amadeus PK) + 'GK', // Sabre passive-for-ticketing usage (≠ Amadeus ghost semantics) + 'HN', // Holding need when present on Sabre air + 'XX', // Cancel residue + ], + TRAVELPORT: [ + 'HN', // Holds need/confirmed (Travelport table) + 'AK', // Passive confirmed outside Galileo (1G) + 'AL', // Passive waitlisted outside Galileo + 'AN', // Passive requested outside Galileo + 'BK', // Passive booked with carrier + 'BL', // Passive waitlist + 'DX', // Broken marriage / authorized partial cancel in marriage + 'MK', // Non-messaging passive + 'PS', // Passive + 'ZK', // Passive API booking + 'LK', // Passive link booking + 'UU', + 'US', + 'XX', + 'XK', // Cancel seg with change + ], + // Host-specific codes must not be applied without a known host. + UNKNOWN: [], +}; const CORE_SET = new Set(CORE_BLOCKING_STATUSES); -const EXTENDED_SET = new Set(EXTENDED_BLOCKING_STATUSES); -/** All statuses that must be cleared before ticketing. */ -export function isBlockingSegmentStatus(status: string): boolean { - const code = status.toUpperCase(); - return CORE_SET.has(code) || EXTENDED_SET.has(code); +const HOST_SETS: Readonly>> = { + AMADEUS: new Set(HOST_BLOCKING_STATUSES.AMADEUS), + SABRE: new Set(HOST_BLOCKING_STATUSES.SABRE), + TRAVELPORT: new Set(HOST_BLOCKING_STATUSES.TRAVELPORT), + UNKNOWN: new Set(HOST_BLOCKING_STATUSES.UNKNOWN), +}; + +function resolveHost(gds: GdsHost | undefined): GdsHost { + return gds ?? 'UNKNOWN'; } +/** Core cross-host set only. */ export function isCoreBlockingStatus(status: string): boolean { return CORE_SET.has(status.toUpperCase()); } +/** + * True if status blocks ticketing for the given host. + * Without a known `gds`, only the core set (HX/UC/UN/NO/TK) applies — + * HN/PK/GK/YK and other host codes are not universalized. + */ +export function isBlockingSegmentStatus( + status: string, + gds?: GdsHost, +): { blocking: boolean; scope: 'core' | 'host' | 'none'; host: GdsHost } { + const code = status.toUpperCase(); + const host = resolveHost(gds); + + if (CORE_SET.has(code)) { + return { blocking: true, scope: 'core', host }; + } + + if (host !== 'UNKNOWN' && HOST_SETS[host].has(code)) { + return { blocking: true, scope: 'host', host }; + } + + return { blocking: false, scope: 'none', host }; +} + /** Travelport: DX indicates broken marriage / marriage integrity risk. */ export function isTravelportMarriageBreakStatus(status: string): boolean { return status.toUpperCase() === 'DX'; } -export type GdsHost = 'SABRE' | 'AMADEUS' | 'TRAVELPORT' | 'UNKNOWN'; - /** Default churn heuristic — overridable; not a carrier-secret table. */ export const DEFAULT_CHURN_CYCLE_THRESHOLD = 3; export const DEFAULT_CHURN_WINDOW_HOURS = 72; diff --git a/packages/agents/settlement/src/adm-prevention/types.ts b/packages/agents/settlement/src/adm-prevention/types.ts index 1692210..58fd108 100644 --- a/packages/agents/settlement/src/adm-prevention/types.ts +++ b/packages/agents/settlement/src/adm-prevention/types.ts @@ -50,7 +50,7 @@ export interface BookingSegment { destination: string; /** Departure date (ISO) */ departure_date: string; - /** Segment status (HK, KK, HX, UC, UN, NO, TK, HN, PK, …) */ + /** Segment status (HK, KK, HX, UC, UN, NO, TK, …). Host-specific codes require `gds`. */ status: string; /** Booked class */ booking_class: string; diff --git a/packages/agents/settlement/src/index.ts b/packages/agents/settlement/src/index.ts index 48e99a9..b3fcc3d 100644 --- a/packages/agents/settlement/src/index.ts +++ b/packages/agents/settlement/src/index.ts @@ -25,8 +25,9 @@ export { ADMPrevention, ADM_CHECK_COUNT, CORE_BLOCKING_STATUSES, - EXTENDED_BLOCKING_STATUSES, + HOST_BLOCKING_STATUSES, isBlockingSegmentStatus, + isCoreBlockingStatus, DEFAULT_CHURN_CYCLE_THRESHOLD, DEFAULT_CHURN_WINDOW_HOURS, } from './adm-prevention/index.js'; From 7722ebf826b7cf5be1b84c7fe13e201a44dedb02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:05:19 +0000 Subject: [PATCH 3/5] docs: clarify per-host status maps in Agent 6.2 KB/spec Co-authored-by: telivity-otaip --- docs/agents/stage-6-settlement.md | 2 +- docs/knowledge-base/adm-prevention.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/agents/stage-6-settlement.md b/docs/agents/stage-6-settlement.md index fadce84..53cd36d 100644 --- a/docs/agents/stage-6-settlement.md +++ b/docs/agents/stage-6-settlement.md @@ -37,7 +37,7 @@ ATPCO Category 33 refund processing: penalty application, commission recall, BSP **Class:** `ADMPrevention` **Status:** Implemented -Pre-ticketing audit with 10 checks: duplicate booking, fare/class mismatch, passive/unable/risky status (HX/UC/UN/NO/TK + extended), churning (history-required), married segment integrity, TTL expiry (timezone / deadline-day), commission rate (caller-supplied only), endorsement box, tour code format, net remit validation. +Pre-ticketing audit with 10 checks: duplicate booking, fare/class mismatch, passive/unable/risky status (core HX/UC/UN/NO/TK + **per-host** maps — HN/PK/GK/YK are not universalized), churning (history-required), married segment integrity, TTL expiry (timezone / deadline-day), commission rate (caller-supplied only), endorsement box, tour code format, net remit validation. **Domain KB:** `docs/knowledge-base/adm-prevention.md` — IATA Reso 850m covers ADM memo windows/dispute (Agent 6.3); passive/UC/churn come from carrier booking policy + host statuses. Travelport: those statuses do not need a ticketing field. No carrier-secret commission tables. diff --git a/docs/knowledge-base/adm-prevention.md b/docs/knowledge-base/adm-prevention.md index 6cabd96..e47a63c 100644 --- a/docs/knowledge-base/adm-prevention.md +++ b/docs/knowledge-base/adm-prevention.md @@ -119,3 +119,5 @@ See `packages/agents/settlement/src/adm-prevention/__tests__/fixtures/`: 2. Sabre MSI normalized field mapping across adapters. 3. Default timezone when `ttl_timezone` omitted (agency vs origin airport vs BSP market). 4. Whether legitimate passive-for-ticketing (host claim / airline-held PNR) should warn instead of block when an airline record locator claim is present — needs claim-flow design, not a guess. +5. Confirm Sabre vs Amadeus **GK** semantics in each adapter's normalized status before treating GK as interchangeable across hosts. +6. When `gds` is UNKNOWN, host-specific codes are ignored (core only) — should UNKNOWN emit a warning instead of silent skip? From d5510510f82c046f055116931523375a4275c5f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 09:14:51 +0000 Subject: [PATCH 4/5] chore: regenerate agent manifest after ADM Prevention 6.2 update Co-authored-by: telivity-otaip --- agents.graph.json | 2 +- agents.manifest.json | 2 +- docs/agent-map.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agents.graph.json b/agents.graph.json index 56d5270..730445f 100644 --- a/agents.graph.json +++ b/agents.graph.json @@ -394,7 +394,7 @@ "id": "6.2", "name": "ADM Prevention", "stage": "settlement", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/settlement/src/adm-prevention/index.ts" diff --git a/agents.manifest.json b/agents.manifest.json index a01fe5b..210e074 100644 --- a/agents.manifest.json +++ b/agents.manifest.json @@ -5357,7 +5357,7 @@ "id": "6.2", "name": "ADM Prevention", "stage": "settlement", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/settlement/src/adm-prevention/index.ts" diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..bd877ca 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- + - +