diff --git a/rtw/booking.py b/rtw/booking.py
index 0477d38..e8ad6db 100644
--- a/rtw/booking.py
+++ b/rtw/booking.py
@@ -206,6 +206,27 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]:
f"Flight {flight_num}: check operating carrier is IB."
)
+ # --- Through-flight via-stop annotation ---
+ via_info = ""
+ if seg.has_via:
+ via_str = ", ".join(seg.via_airports)
+ via_info = f"\n Through-flight via: {via_str} (book as single segment)"
+ warnings.append(
+ f"Through-flight {route} via {via_str}: must be booked as a "
+ f"single segment. Splitting into separate segments requires "
+ f"reissue and may incur fees."
+ )
+
+ # --- CX hub-connection warning ---
+ if seg.carrier == "CX":
+ from_apt = seg.from_airport
+ to_apt = seg.to_airport
+ if from_apt != "HKG" and to_apt != "HKG":
+ warnings.append(
+ f"CX {from_apt}-{to_apt}: D-class may be married through HKG. "
+ f"Check standalone availability via ExpertFlyer."
+ )
+
# --- Build phone instruction ---
date_str = seg.date.strftime("%d %b %Y") if seg.date else "date TBD"
flight_info = f" flight {seg.flight}" if seg.flight else ""
@@ -214,6 +235,8 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]:
f"Segment {i + 1}: {seg.carrier}{flight_info} — {route} — {date_str}\n"
f" Booking class: {booking_class}"
)
+ if via_info:
+ instruction += via_info
if fj_atr_note:
instruction += fj_atr_note
if seg.notes:
diff --git a/rtw/data/through_flights.yaml b/rtw/data/through_flights.yaml
new file mode 100644
index 0000000..802eb33
--- /dev/null
+++ b/rtw/data/through_flights.yaml
@@ -0,0 +1,67 @@
+# Known oneworld through-flights with cross-continent impact
+# Through-flight = single flight number with intermediate stop(s)
+# Counts as ONE segment per Rule 3015, but ALL stop-continents count for pricing (§16)
+#
+# Fields:
+# carrier: 2-letter IATA code
+# flights: list of flight numbers
+# from/to: origin/destination airport codes
+# via: list of intermediate stop airports
+# continents_added: continents gained from via stops (not from from/to)
+# notes: optional context
+#
+# Only includes through-flights where via stop adds a DIFFERENT continent
+# than the origin or destination (i.e., cross-continent impact).
+# Through-flights where via stop is in the same continent as from/to
+# (e.g., QF5/6 SYD-PER-FCO where PER=SWP same as SYD) are listed
+# separately under "same_continent_via" for reference.
+
+cross_continent:
+ - carrier: QF
+ flights: ["QF1", "QF2"]
+ from: SYD
+ to: LHR
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "Flagship A380 service. SIN stop adds Asia to SWP+EU_ME route."
+
+ - carrier: BA
+ flights: ["BA15", "BA16"]
+ from: LHR
+ to: SYD
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "BA's Australia service. SIN stop adds Asia to EU_ME+SWP route."
+
+ - carrier: QR
+ flights: ["QR920", "QR921"]
+ from: DOH
+ to: ADL
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "Qatar to Adelaide. SIN stop adds Asia to EU_ME+SWP route."
+
+ - carrier: QR
+ flights: ["QR908", "QR909"]
+ from: DOH
+ to: MEL
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "Qatar to Melbourne. SIN stop adds Asia to EU_ME+SWP route."
+
+same_continent_via:
+ - carrier: QF
+ flights: ["QF5", "QF6"]
+ from: SYD
+ to: FCO
+ via: [PER]
+ continents_added: []
+ notes: "PER is SWP (same as SYD). No cross-continent impact."
+
+ - carrier: QF
+ flights: ["QF3", "QF4"]
+ from: SYD
+ to: JFK
+ via: [AKL]
+ continents_added: []
+ notes: "AKL is SWP (same as SYD). No cross-continent impact."
diff --git a/rtw/rules/married.py b/rtw/rules/married.py
new file mode 100644
index 0000000..cda27e3
--- /dev/null
+++ b/rtw/rules/married.py
@@ -0,0 +1,103 @@
+"""Married segment pattern detection. Based on community-reported patterns."""
+
+from rtw.rules.base import register_rule
+from rtw.models import RuleResult, Severity
+
+
+@register_rule
+class MarriedSegmentRule:
+ """Detect married segment risks from known airline patterns."""
+
+ rule_id = "married_segment"
+ rule_name = "Married Segment Detection"
+ rule_reference = "Community knowledge (FlyerTalk)"
+
+ # Carriers with known hub-connection married segment patterns
+ _HUB_CARRIERS = {
+ "CX": ("HKG", "Cathay Pacific often requires HKG stopover for D-class availability"),
+ }
+
+ def check(self, itinerary, context) -> list[RuleResult]:
+ results = []
+
+ # Check 1: CX hub-connection patterns
+ for i, seg in enumerate(itinerary.segments):
+ if not seg.is_flown or not seg.carrier:
+ continue
+ if seg.carrier in self._HUB_CARRIERS:
+ hub, reason = self._HUB_CARRIERS[seg.carrier]
+ from_apt = seg.from_airport
+ to_apt = seg.to_airport
+ # If neither endpoint is the hub, D-class may be married through hub
+ if from_apt != hub and to_apt != hub:
+ results.append(
+ RuleResult(
+ rule_id=self.rule_id,
+ rule_name=self.rule_name,
+ rule_reference=self.rule_reference,
+ passed=False,
+ severity=Severity.INFO,
+ message=(
+ f"Seg {i+1} {from_apt}-{to_apt} on {seg.carrier}: "
+ f"{reason}. D-class may only be available "
+ f"on connecting itineraries through {hub}."
+ ),
+ fix_suggestion=(
+ f"Check if adding a {hub} stopover improves D-class availability, "
+ f"or verify standalone availability via ExpertFlyer."
+ ),
+ segments_involved=[i],
+ )
+ )
+
+ # Check 2: Through-flight split risk
+ # If a segment has a via stop, and that via city appears as a
+ # stopover destination elsewhere, splitting could trigger reissue fees.
+ via_cities = {} # via_airport -> segment_index
+ stopover_cities = set()
+
+ for i, seg in enumerate(itinerary.segments):
+ if seg.has_via:
+ for via_apt in seg.via_airports:
+ via_cities[via_apt] = i
+ if seg.is_stopover:
+ stopover_cities.add(seg.to_airport)
+
+ for via_apt, seg_idx in via_cities.items():
+ if via_apt in stopover_cities:
+ seg = itinerary.segments[seg_idx]
+ results.append(
+ RuleResult(
+ rule_id=self.rule_id,
+ rule_name=self.rule_name,
+ rule_reference="Qantas AgencyConnect",
+ passed=False,
+ severity=Severity.INFO,
+ message=(
+ f"Seg {seg_idx+1} {seg.from_airport}-{seg.to_airport} "
+ f"is a through-flight via {via_apt}, but {via_apt} also "
+ f"appears as a stopover. Splitting the through-flight "
+ f"into separate segments may require reissue + fee."
+ ),
+ fix_suggestion=(
+ f"Keep {seg.from_airport}-{seg.to_airport} as a single "
+ f"through-flight segment. If you need a stopover in "
+ f"{via_apt}, book separate segments instead."
+ ),
+ segments_involved=[seg_idx],
+ )
+ )
+
+ if not results:
+ results.append(
+ RuleResult(
+ rule_id=self.rule_id,
+ rule_name=self.rule_name,
+ rule_reference=self.rule_reference,
+ passed=True,
+ severity=Severity.INFO,
+ message="No married segment risks detected.",
+ )
+ )
+
+ return results
diff --git a/rtw/through_flights.py b/rtw/through_flights.py
new file mode 100644
index 0000000..75d4c28
--- /dev/null
+++ b/rtw/through_flights.py
@@ -0,0 +1,47 @@
+"""Through-flight reference data loader.
+
+Known oneworld through-flights where intermediate stops add
+cross-continent impact for pricing (Rule 3015 §16).
+"""
+
+from pathlib import Path
+from typing import Optional
+
+import yaml
+
+_DATA_PATH = Path(__file__).parent / "data" / "through_flights.yaml"
+_CACHE: Optional[dict] = None
+
+
+def load_through_flights() -> dict:
+ """Load through-flight reference data."""
+ global _CACHE
+ if _CACHE is None:
+ with open(_DATA_PATH) as f:
+ _CACHE = yaml.safe_load(f)
+ return _CACHE
+
+
+def get_cross_continent_flights() -> list[dict]:
+ """Return only through-flights with cross-continent impact."""
+ data = load_through_flights()
+ return data.get("cross_continent", [])
+
+
+def lookup_via_airports(carrier: str, from_apt: str, to_apt: str) -> list[str]:
+ """Look up known via airports for a carrier/route pair.
+
+ Returns list of via airport codes, or empty list if not a known through-flight.
+ This is reference-only — users must still add `via:` explicitly to their segments.
+ """
+ for entry in get_cross_continent_flights():
+ if (entry["carrier"] == carrier
+ and entry["from"] == from_apt
+ and entry["to"] == to_apt):
+ return entry.get("via", [])
+ # Check reverse direction too
+ if (entry["carrier"] == carrier
+ and entry["from"] == to_apt
+ and entry["to"] == from_apt):
+ return entry.get("via", [])
+ return []
diff --git a/rtw/validator.py b/rtw/validator.py
index be0af49..1a5a62e 100644
--- a/rtw/validator.py
+++ b/rtw/validator.py
@@ -46,6 +46,10 @@ class ValidationContext:
implicit_continents: list[Continent] = field(default_factory=list)
# Segments that triggered implicit continent detection {continent: [seg_indices]}
implicit_continent_segments: dict[Continent, list[int]] = field(default_factory=dict)
+ # Via-stop continent visits (through-flight technical stops)
+ via_continents: list[Continent] = field(default_factory=list)
+ # Segments that triggered via-continent detection {continent: [(seg_index, via_airport)]}
+ via_continent_segments: dict[Continent, list[tuple[int, str]]] = field(default_factory=dict)
def build_context(itinerary: Itinerary) -> ValidationContext:
@@ -132,12 +136,43 @@ def build_context(itinerary: Itinerary) -> ValidationContext:
ctx.intercontinental_arrivals = ic_arrivals
ctx.intercontinental_departures = ic_departures
+ # Count via-stop continents (through-flight technical stops).
+ # Via stops affect continent pricing but NOT per-continent segment limits.
+ _detect_via_continents(ctx, itinerary)
+
# Detect implicit continent visits (e.g., Asia for EU_ME<->SWP flights)
_detect_implicit_continents(ctx, itinerary)
return ctx
+def _detect_via_continents(ctx: ValidationContext, itinerary: Itinerary) -> None:
+ """Count continents from through-flight via stops (Rule 3015 §16).
+
+ Through-flights with intermediate stops count each stop's continent
+ as visited for pricing, but do NOT add to per-continent segment limits
+ (the through-flight is still one segment).
+ """
+ via_segments: dict[Continent, list[tuple[int, str]]] = {}
+
+ for i, seg in enumerate(itinerary.segments):
+ if not seg.has_via:
+ continue
+ for via_apt in seg.via_airports:
+ via_cont = get_continent(via_apt)
+ if via_cont is None:
+ continue
+ if via_cont not in via_segments:
+ via_segments[via_cont] = []
+ via_segments[via_cont].append((i, via_apt))
+ # Add to continents_visited if not already present
+ if via_cont not in ctx.continents_visited:
+ ctx.continents_visited.append(via_cont)
+
+ ctx.via_continent_segments = via_segments
+ ctx.via_continents = list(via_segments.keys())
+
+
def _detect_implicit_continents(ctx: ValidationContext, itinerary: Itinerary) -> None:
"""Detect implicit continent visits per Rule 3015 §16.
@@ -193,6 +228,7 @@ def _discover_rules(self) -> None:
import rtw.rules.hemisphere # noqa: F401
import rtw.rules.intercontinental # noqa: F401
import rtw.rules.country # noqa: F401
+ import rtw.rules.married # noqa: F401
def validate(self, itinerary: Itinerary) -> ValidationReport:
"""Run all rules and return a validation report."""
diff --git a/rtw/verify/models.py b/rtw/verify/models.py
index deacec6..84bd04f 100644
--- a/rtw/verify/models.py
+++ b/rtw/verify/models.py
@@ -135,6 +135,7 @@ class SegmentVerification(BaseModel):
flight_number: Optional[str] = None
target_date: Optional[datetime.date] = None
dclass: Optional[DClassResult] = None
+ married_segment_note: Optional[str] = None
class VerifyOption(BaseModel):
diff --git a/rtw/verify/verifier.py b/rtw/verify/verifier.py
index 02df48b..439d3ac 100644
--- a/rtw/verify/verifier.py
+++ b/rtw/verify/verifier.py
@@ -27,6 +27,12 @@
_CACHE_TTL_HOURS = 24
_CACHE_KEY_PREFIX = "dclass"
+# Carriers with known married segment patterns (hub-connection)
+_MARRIED_CHECK_HUBS = {
+ "CX": "HKG",
+ "QR": "DOH",
+}
+
class DClassVerifier:
"""Verify award class availability for itinerary segments.
@@ -96,6 +102,33 @@ def _store_cache(self, seg: SegmentVerification, result: DClassResult) -> None:
key = self._cache_key(seg)
self.cache.set(key, result.model_dump(mode="json"), ttl_hours=_CACHE_TTL_HOURS)
+ def _check_married_pattern(
+ self, seg: SegmentVerification, result: DClassResult
+ ) -> Optional[str]:
+ """Detect married segment patterns from ExpertFlyer results.
+
+ If a carrier has a known hub and nonstop has 0 seats but
+ connecting flights have seats > 0, flag as likely married.
+ """
+ carrier = seg.carrier
+ if not carrier or carrier not in _MARRIED_CHECK_HUBS:
+ return None
+ hub = _MARRIED_CHECK_HUBS[carrier]
+ # Skip if either endpoint IS the hub (not married in that case)
+ if seg.origin == hub or seg.destination == hub:
+ return None
+ # Check: nonstop has 0 seats but connections have seats
+ nonstop_seats = result.nonstop_seats
+ connecting_with_seats = [
+ f for f in result.flights if f.stops > 0 and f.seats > 0
+ ]
+ if nonstop_seats == 0 and connecting_with_seats:
+ return (
+ f"{result.booking_class}-class only via connection "
+ f"(likely married through {hub})"
+ )
+ return None
+
def verify_option(
self,
option: VerifyOption,
@@ -174,6 +207,10 @@ def verify_option(
dclass.booking_class = seg_bc
verified.dclass = dclass
self._store_cache(seg, dclass)
+ # Check for married segment pattern
+ verified.married_segment_note = self._check_married_pattern(
+ seg, dclass
+ )
else:
verified.dclass = DClassResult(
status=DClassStatus.UNKNOWN,
diff --git a/specs/via-through-flights/.progress.md b/specs/via-through-flights/.progress.md
new file mode 100644
index 0000000..43042c1
--- /dev/null
+++ b/specs/via-through-flights/.progress.md
@@ -0,0 +1,48 @@
+# via-through-flights
+
+## Original Goal
+Add a `via` field to segments for through-flight technical stops, enabling the validator to count intermediate continent visits without consuming extra segments. Include a known through-flight lookup table and extend ImplicitAsiaRule to detect via-stop continents.
+
+## Interview Format
+- Version: 1.0
+
+## Intent Classification
+- Type: GREENFIELD
+- Confidence: high (4 keywords matched)
+- Min questions: 5
+- Max questions: 10
+- Keywords matched: add, implement, extend, include
+
+## Interview Responses
+
+### Goal Interview (from start.md)
+- Problem: Both — missing continent counting from through-flight technical stops AND segment optimization guidance for connections vs through-flights
+- Constraints: Through-flight lookup auto-detection NOT wanted — only explicit `via` field triggers continent counting. Architecture decision deferred to architect for data storage location.
+- Success criteria: Via field works in YAML, validator counts via-stop continents, known through-flight data available, married segment warnings with static rules + ExpertFlyer integration
+- Scope: Full booking intelligence — includes married segment detection (CX/HKG, QF D-class), availability warnings, connection time analysis. Data: static known patterns + ExpertFlyer live checks when credentials configured.
+
+### Research Q&A
+- Through-flight YAML: Simple static list (no seasonal date ranges). Seasonal awareness is a separate concern.
+- Married segment severity: INFO for static pattern detection, WARNING for ExpertFlyer-confirmed married segments.
+- ExpertFlyer married detection: Always check married segments during verify (user overrode opt-in recommendation). Rate limiting needs careful handling.
+- Via field type: `str | list[str]` — accept both single string and list, normalize to list internally via Pydantic validator.
+- Via-stop segment limits: Via stop adds continent to visited list but does NOT count toward per-continent segment limit (through-flight = one segment per Rule 3015).
+
+## Learnings
+- Through-flight = single flight number with intermediate stop. Counts as ONE segment per Rule 3015, but ALL stop-continents count for pricing (SS16).
+- Qantas AgencyConnect explicitly states through-flights must be booked as single segment; splitting requires reissue + $125 fee.
+- Known cross-continent oneworld through-flights with continent impact: QF1/2 (SIN), BA15/16 (SIN), QR920/921 (SIN), QR908/909 (SIN). SIN is the dominant cross-continent via point.
+- QF5/6 SYD-PER-ROM and QF3/4 SYD-AKL-JFK have via stops but NO cross-continent impact (PER=SWP, AKL=SWP).
+- CX routes via HKG don't add extra continents since HKG is already Asia.
+- Existing `_detect_implicit_continents()` handles nonstop EU_ME<->SWP case (e.g., QF PER-LHR). Via-stop counting is SUPPLEMENTARY, not a replacement.
+- ExpertFlyer married segment detection requires paired queries (direct vs. connecting) — doubles query count. Rate limiting is a real concern.
+- Married segment patterns are empirical/community knowledge, not officially documented by airlines. CX hub-connection and QF standalone-D patterns are the most commonly reported.
+- AA shifted from POS to POC availability in Aug 2024, which affects how D-class availability is displayed based on where the journey starts.
+- Booking.py already has basic married segment warning for same-day transit (lines 179-196). Extension point for richer warnings.
+- 37 existing YAML test fixtures — all would be unaffected by optional `via: null` default field.
+- Synthesis: Via-continent counting goes in build_context() BEFORE _detect_implicit_continents() so both mechanisms work independently. Via adds explicit stops; implicit handles nonstop EU_ME<->SWP flights.
+- Synthesis: Separate via_continents and via_continent_segments fields on ValidationContext keep via-sourced continents distinct from implicit ones — important for debugging and display.
+- Synthesis: ExpertFlyer married detection simplified — existing connection results already contain connecting flights with stops>0. No extra paired queries needed for basic detection. Just check if D-class only available on connecting flights (stops>0) but not nonstop (stops==0).
+- Synthesis: MarriedSegmentRule as @register_rule follows rule engine pattern exactly. Will be rule 25 across 11 files. Discovery via explicit import in _discover_rules().
+- Synthesis: Pydantic field_validator pattern for via normalization matches existing uppercase_airports pattern on lines 145-148 of models.py. No new patterns introduced.
+- Synthesis: Through-flight YAML is reference-only data. No auto-population logic. Users must add `via:` to their segments explicitly. This avoids the auto-detection complexity the user explicitly rejected.
diff --git a/specs/via-through-flights/design.md b/specs/via-through-flights/design.md
new file mode 100644
index 0000000..d5a350a
--- /dev/null
+++ b/specs/via-through-flights/design.md
@@ -0,0 +1,361 @@
+---
+spec: via-through-flights
+phase: design
+created: 2026-02-16
+generated: auto
+---
+
+# Design: via-through-flights
+
+## Overview
+
+Extend the Segment model with an optional `via` field, add via-stop continent counting to the validator context builder, create a married segment rule file in the rules engine, add a through-flight reference data file, and enhance booking script + verify modules with married segment intelligence.
+
+## Architecture
+
+```mermaid
+graph TB
+ YAML[YAML Itinerary] --> Model[Segment Model
+ via field]
+ Model --> Validator[build_context
+ via continent counting]
+ Validator --> Rules[Rule Engine]
+ Rules --> Married[rules/married.py
static patterns]
+ Model --> Booking[BookingGenerator
+ through-flight warnings]
+ Model --> Verify[DClassVerifier
+ paired queries]
+ Data[through_flights.yaml] -.-> CLI[CLI show/analyze]
+ Verify --> MarriedFlag[MarriedSegmentInfo
on SegmentVerification]
+```
+
+## Components
+
+### Component A: Segment Model Extension
+**Purpose**: Add `via` field to Segment for through-flight technical stops
+**File**: `rtw/models.py`
+**Responsibilities**:
+- Accept `via` as `Optional[str | list[str]]` with `None` default
+- Normalize to `list[str]` via Pydantic field_validator
+- Uppercase airport codes
+- Expose `has_via` property and `via_airports` normalized accessor
+
+**Design**:
+```python
+class Segment(BaseModel):
+ # ... existing fields ...
+ via: Optional[str | list[str]] = None
+
+ @field_validator("via", mode="before")
+ @classmethod
+ def normalize_via(cls, v):
+ if v is None:
+ return None
+ if isinstance(v, str):
+ return [v.upper()]
+ return [x.upper() for x in v]
+
+ @property
+ def has_via(self) -> bool:
+ return self.via is not None and len(self.via) > 0
+
+ @property
+ def via_airports(self) -> list[str]:
+ return self.via or []
+```
+
+**Backward Compatibility**: `via` defaults to `None`. All existing YAML fixtures are unaffected. The `populate_by_name` model config already handles alias resolution.
+
+### Component B: Validator Context Extension
+**Purpose**: Count via-stop continents in `build_context()`
+**File**: `rtw/validator.py`
+**Responsibilities**:
+- After resolving segment continents, iterate via airports and resolve their continents
+- Add via-stop continents to `continents_visited` (not to `segments_per_continent`)
+- Track via-stop continent sources in new `via_continents` and `via_continent_segments` context fields
+
+**Design**:
+```python
+@dataclass
+class ValidationContext:
+ # ... existing fields ...
+ # Via-stop continent visits {continent: [(seg_index, via_airport)]}
+ via_continents: list[Continent] = field(default_factory=list)
+ via_continent_segments: dict[Continent, list[tuple[int, str]]] = field(default_factory=dict)
+```
+
+**Extension to `build_context()`**:
+After the main segment loop and before `_detect_implicit_continents()`:
+```python
+# Count via-stop continents
+via_cont_segs: dict[Continent, list[tuple[int, str]]] = {}
+for i, seg in enumerate(itinerary.segments):
+ for via_apt in seg.via_airports:
+ via_cont = get_continent(via_apt)
+ if via_cont and via_cont not in seen_continents:
+ seen_continents.append(via_cont)
+ if via_cont:
+ if via_cont not in via_cont_segs:
+ via_cont_segs[via_cont] = []
+ via_cont_segs[via_cont].append((i, via_apt))
+ctx.via_continents = list(via_cont_segs.keys())
+ctx.via_continent_segments = via_cont_segs
+```
+
+**Key rule**: Via-stop continents add to `continents_visited` (pricing) but NOT to `segments_per_continent` (segment limits). A through-flight remains one segment.
+
+### Component C: Through-Flight Reference Data
+**Purpose**: Static lookup table of known oneworld through-flights
+**File**: `rtw/data/through_flights.yaml`
+**Responsibilities**:
+- Store carrier, flight number, origin, destination, via stops, and continents added
+- Serve as reference for users; NOT auto-applied to segments
+
+**Data Schema**:
+```yaml
+# Known oneworld through-flights with cross-continent via stops
+# Reference data only — add `via:` to your YAML segment to trigger continent counting
+
+through_flights:
+ - carrier: QF
+ flights: ["QF1", "QF2"]
+ from: SYD
+ to: LHR
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "Sydney-Singapore-London. SIN stop adds Asia to continent count."
+
+ - carrier: BA
+ flights: ["BA15", "BA16"]
+ from: LHR
+ to: SYD
+ via: [SIN]
+ continents_added: [Asia]
+ notes: "London-Singapore-Sydney. Married segments at SIN — splitting requires reissue."
+
+ - carrier: QR
+ flights: ["QR920", "QR921"]
+ from: DOH
+ to: ADL
+ via: [SIN]
+ continents_added: [Asia]
+
+ - carrier: QR
+ flights: ["QR908", "QR909"]
+ from: DOH
+ to: MEL
+ via: [SIN]
+ continents_added: [Asia]
+
+ # Same-continent via stops (no pricing impact)
+ - carrier: QF
+ flights: ["QF5", "QF6"]
+ from: SYD
+ to: FCO
+ via: [PER]
+ continents_added: []
+ notes: "PER is SWP — no extra continent."
+
+ - carrier: QF
+ flights: ["QF3", "QF4"]
+ from: SYD
+ to: JFK
+ via: [AKL]
+ continents_added: []
+ notes: "AKL is SWP — no extra continent."
+```
+
+### Component D: Married Segment Rules
+**Purpose**: Detect married segment risk patterns via static analysis
+**File**: `rtw/rules/married.py`
+**Responsibilities**:
+- CX hub-connection pattern: CX segments where HKG is a connection, not an endpoint
+- QF standalone long-haul: QF intercontinental without domestic QF feeder
+- Through-flight split warning: via-stop city has a stopover elsewhere in itinerary
+- Registered via `@register_rule` decorator
+
+**Design**:
+```python
+@register_rule
+class MarriedSegmentRule:
+ rule_id = "married_segments"
+ rule_name = "Married Segment Risks"
+ rule_reference = "Booking Advisory"
+
+ def check(self, itinerary, context) -> list[RuleResult]:
+ results = []
+ results.extend(self._check_cx_hub(itinerary, context))
+ results.extend(self._check_through_flight_split(itinerary))
+ # Return INFO pass result if no issues found
+ if not results:
+ results.append(RuleResult(
+ rule_id=self.rule_id, rule_name=self.rule_name,
+ rule_reference=self.rule_reference,
+ passed=True, message="No married segment risks detected."
+ ))
+ return results
+```
+
+**CX Hub Pattern**:
+```python
+def _check_cx_hub(self, itinerary, context):
+ # Find CX segments where neither from nor to is HKG
+ # but there IS a CX-HKG connection elsewhere in itinerary
+ cx_has_hkg = any(
+ s.carrier == "CX" and (s.from_airport == "HKG" or s.to_airport == "HKG")
+ for s in itinerary.segments if s.is_flown
+ )
+ results = []
+ for i, seg in enumerate(itinerary.segments):
+ if seg.carrier == "CX" and seg.is_flown:
+ if seg.from_airport != "HKG" and seg.to_airport != "HKG":
+ results.append(RuleResult(
+ rule_id=self.rule_id, ...,
+ passed=False, severity=Severity.INFO,
+ message=f"CX {seg.from_airport}-{seg.to_airport}: D-class may only be "
+ f"available as married segment through HKG.",
+ segments_involved=[i],
+ ))
+ return results
+```
+
+**Through-Flight Split Detection**:
+```python
+def _check_through_flight_split(self, itinerary):
+ # If a segment has via stops, check if any via-stop city also has
+ # a stopover elsewhere — indicates user may want to split
+ results = []
+ stopover_cities = {s.to_airport for s in itinerary.segments if s.is_stopover}
+ for i, seg in enumerate(itinerary.segments):
+ for via_apt in seg.via_airports:
+ if via_apt in stopover_cities:
+ results.append(RuleResult(
+ rule_id=self.rule_id, ...,
+ passed=False, severity=Severity.INFO,
+ message=f"Through-flight via {via_apt} on segment {i+1} "
+ f"({seg.from_airport}-{seg.to_airport}): splitting to "
+ f"stopover at {via_apt} converts 1 segment to 2 + $125 reissue.",
+ ))
+ return results
+```
+
+### Component E: Booking Script Enhancement
+**Purpose**: Add through-flight and married segment warnings to phone scripts
+**File**: `rtw/booking.py`
+**Responsibilities**:
+- Detect segments with `via` field and add through-flight annotations
+- Add GDS comment for through-flights
+- Enhance existing married segment warning with carrier-specific context
+
+**Design changes to `_segment_scripts()`**:
+```python
+# --- Through-flight via-stop annotation ---
+if seg.has_via:
+ via_str = ", ".join(seg.via_airports)
+ from rtw.continents import get_continent
+ via_conts = [get_continent(v) for v in seg.via_airports]
+ cont_names = [c.value for c in via_conts if c]
+ warnings.append(
+ f"Through-flight via {via_str} — one segment, "
+ f"counts {', '.join(cont_names)} for pricing."
+ )
+ instruction += f"\n Via: {via_str} (through-flight, single segment)"
+```
+
+### Component F: ExpertFlyer Married Segment Detection
+**Purpose**: Compare direct vs. connection availability to detect married segments
+**File**: `rtw/verify/verifier.py` (extend `DClassVerifier`)
+**Responsibilities**:
+- For known hub carriers (CX/HKG, QR/DOH), run paired queries during verify
+- Flag segments where D-class is only available via connection
+- Add `married_segment` field to `SegmentVerification`
+
+**Design**:
+```python
+# In DClassVerifier
+_MARRIED_CHECK_HUBS = {
+ "CX": "HKG",
+ "QR": "DOH",
+}
+
+async def _check_married(self, seg, direct_result) -> Optional[str]:
+ """Check if a segment shows married segment pattern."""
+ hub = self._MARRIED_CHECK_HUBS.get(seg.carrier)
+ if not hub or seg.origin == hub or seg.destination == hub:
+ return None
+ # Direct has no D-class; check via hub
+ if direct_result.seats > 0:
+ return None # Direct available, no married concern
+ # Would need connection query via hub — but ExpertFlyer
+ # connection search already returns connecting flights in results
+ # Check if any connection flights via hub have D-class
+ connecting = [f for f in direct_result.flights if f.stops > 0 and f.seats > 0]
+ if connecting:
+ return f"D-class only via connection (likely married through {hub})"
+ return None
+```
+
+**New field on SegmentVerification**:
+```python
+class SegmentVerification(BaseModel):
+ # ... existing fields ...
+ married_segment_note: Optional[str] = None
+```
+
+## Data Flow
+
+1. User adds `via: SIN` to YAML segment
+2. Pydantic normalizes to `via: ["SIN"]` on Segment model
+3. `build_context()` resolves SIN -> Asia, adds to `continents_visited`
+4. Rules engine runs `MarriedSegmentRule` for static pattern detection
+5. `rtw verify` runs ExpertFlyer queries; paired queries detect married patterns
+6. `BookingGenerator` adds through-flight annotations to phone script
+7. `rtw show` displays via-stop info alongside segment details
+
+## Technical Decisions
+
+| Decision | Options | Choice | Rationale |
+|----------|---------|--------|-----------|
+| Via field type | `str`, `list[str]`, `str \| list[str]` | `str \| list[str]` | Convenience: single string for common case, list for rare multi-stop |
+| Via normalization | Runtime helper, Pydantic validator | Pydantic field_validator | Follows existing pattern (`uppercase_airports`); normalized at parse time |
+| Via continent tracking | Merge into implicit_continents, separate field | Separate `via_continents` field | Different source; keeps implicit EU_ME<->SWP rule distinct from explicit via |
+| Married rule location | `booking.py`, `rules/married.py` | `rules/married.py` | Follows rule engine pattern; discoverable; testable independently |
+| Through-flight data format | Python dict, JSON, YAML | YAML | Consistent with existing data files in `rtw/data/` |
+| ExpertFlyer married check | Opt-in flag, always-on | Always-on | User decision: always check during verify; rate limiting handles load |
+
+## File Structure
+
+| File | Action | Purpose |
+|------|--------|---------|
+| `rtw/models.py` | Modify | Add `via` field + validator + properties to Segment |
+| `rtw/validator.py` | Modify | Add via-continent fields to ValidationContext; extend `build_context()` |
+| `rtw/data/through_flights.yaml` | Create | Known through-flight reference data |
+| `rtw/rules/married.py` | Create | Married segment static pattern rules |
+| `rtw/booking.py` | Modify | Through-flight and married segment warnings in phone scripts |
+| `rtw/verify/models.py` | Modify | Add `married_segment_note` to SegmentVerification |
+| `rtw/verify/verifier.py` | Modify | Paired query logic for married segment detection |
+| `rtw/validator.py` | Modify | Register married rule module in `_discover_rules()` |
+| `rtw/cli.py` | Modify | Show via-stop info in `show` and `analyze` output |
+| `tests/test_models.py` | Modify | Tests for via field normalization |
+| `tests/test_validator.py` | Modify | Tests for via-continent counting |
+| `tests/test_rules/test_married.py` | Create | Tests for married segment rules |
+| `tests/test_booking.py` | Modify | Tests for through-flight booking warnings |
+| `tests/fixtures/via_through_flight.yaml` | Create | Test fixture with via stops |
+| `tests/test_integration.py` | Modify | Integration test with through-flight itinerary |
+
+## Error Handling
+
+| Error | Handling | User Impact |
+|-------|----------|-------------|
+| Invalid via airport code | Pydantic validation error at parse time | Clear error: "via airport must be 3-letter IATA code" |
+| Unknown via airport continent | `get_continent()` returns None; skip (no continent added) | Silent skip; airport still shown in output but no continent counted |
+| ExpertFlyer paired query timeout | Catch timeout, return direct result only | Married detection skipped; direct availability still shown |
+| ExpertFlyer rate limit hit during paired query | Skip paired query, use existing direct result | Log warning; married detection deferred |
+| Through-flight YAML parse error | Fail-fast on load (same as other data files) | Startup error with clear message |
+
+## Existing Patterns to Follow
+
+- **Field validator pattern**: `rtw/models.py:145-158` — `@field_validator` with `mode="before"`, `@classmethod`, uppercase normalization
+- **Optional field pattern**: `rtw/models.py:136-141` — `Optional[str] = Field(default=None)` with model_config `populate_by_name`
+- **Rule registration**: `rtw/rules/geography.py:66` — `@register_rule` decorator on class with `rule_id`, `rule_name`, `rule_reference`, `check()` method
+- **Rule discovery**: `rtw/validator.py:184-195` — explicit import in `_discover_rules()`
+- **Data loading**: `rtw/continents.py:15-16` — `yaml.safe_load()` from `_DATA_DIR`
+- **Context dataclass**: `rtw/validator.py:17-48` — `@dataclass` with `field(default_factory=list/dict)`
+- **Booking warnings**: `rtw/booking.py:179-196` — append to `warnings: list[str]` in `_segment_scripts()`
+- **Verify models**: `rtw/verify/models.py:127-136` — `SegmentVerification` with optional fields
diff --git a/specs/via-through-flights/requirements.md b/specs/via-through-flights/requirements.md
new file mode 100644
index 0000000..ef1b5c6
--- /dev/null
+++ b/specs/via-through-flights/requirements.md
@@ -0,0 +1,131 @@
+---
+spec: via-through-flights
+phase: requirements
+created: 2026-02-16
+generated: auto
+---
+
+# Requirements: via-through-flights
+
+## Summary
+
+Add a `via` field to itinerary segments to model through-flight technical stops, enabling the validator to count intermediate continent visits without consuming extra segments. Provide a known through-flight reference table and extend booking intelligence with married segment detection (static patterns + ExpertFlyer live checks).
+
+## User Stories
+
+### US-1: Declare through-flight via stops in YAML
+
+As an RTW planner, I want to add a `via` field to a segment so that the validator counts the intermediate stop's continent for pricing without adding an extra segment.
+
+**Acceptance Criteria**:
+- AC-1.1: `via: SIN` (string) and `via: [SIN]` (list) both accepted; normalized to list internally
+- AC-1.2: Via airports validated as 3-letter IATA codes, uppercased automatically
+- AC-1.3: Omitting `via` field (or `via: null`) has no effect; all existing YAML fixtures remain valid
+- AC-1.4: `via` field appears in `rtw show` output when present
+- AC-1.5: Multiple via stops supported: `via: [SIN, KUL]`
+
+### US-2: Count via-stop continents for pricing
+
+As an RTW planner, I want via-stop continents to be counted as "visited" for continent pricing so that my ticket type matches reality (e.g., QF1 SYD-SIN-LHR counts Asia).
+
+**Acceptance Criteria**:
+- AC-2.1: Via-stop airport resolved to continent using existing `get_continent()` function
+- AC-2.2: Via-stop continent added to `continents_visited` if not already present
+- AC-2.3: Via-stop does NOT count toward per-continent segment limit (through-flight = one segment)
+- AC-2.4: Via-stop does NOT increment `segments_per_continent` for the via continent
+- AC-2.5: Same-continent via stops (e.g., PER for SWP->SWP) have no effect on continent count
+- AC-2.6: Via-stop continent counting supplements (does not replace) existing implicit EU_ME<->SWP Asia detection
+
+### US-3: Reference known through-flights
+
+As an RTW planner, I want to look up known oneworld through-flights so that I know which routes have cross-continent technical stops.
+
+**Acceptance Criteria**:
+- AC-3.1: `rtw/data/through_flights.yaml` contains known through-flights with carrier, flight number, from, to, via, and continents_added fields
+- AC-3.2: Data is reference-only; does NOT auto-populate `via` on segments (user must add `via` explicitly)
+- AC-3.3: Data includes at minimum: QF1/2, BA15/16, QR920/921, QR908/909
+- AC-3.4: Data easily editable (simple YAML, no complex schema)
+
+### US-4: Detect married segment risks (static patterns)
+
+As an RTW planner, I want the validator to warn about married segment risks so that I can plan connections that are actually bookable.
+
+**Acceptance Criteria**:
+- AC-4.1: CX hub-connection pattern detected: CX segment where neither endpoint is HKG, but itinerary has CX connection through HKG
+- AC-4.2: QF standalone D-class pattern: QF long-haul segment without QF domestic connection
+- AC-4.3: Through-flight split warning: segment with `via` field where via-stop city has a stopover (splitting = reissue fee)
+- AC-4.4: Same-day transit warning (existing) preserved and enhanced with carrier-specific context
+- AC-4.5: Static pattern warnings have severity INFO
+- AC-4.6: Married segment rules registered in rule engine via `@register_rule`
+
+### US-5: Detect married segments via ExpertFlyer
+
+As an RTW planner, I want ExpertFlyer verification to always check for married segment patterns so that I get live availability intelligence.
+
+**Acceptance Criteria**:
+- AC-5.1: During `rtw verify`, compare direct availability vs. connection availability for known hub carriers (CX/HKG, QR/DOH)
+- AC-5.2: If D-class available only via connection but not standalone, flag as "married segment detected" with WARNING severity
+- AC-5.3: Rate limiting: paired queries count toward daily soft limit; throttled appropriately
+- AC-5.4: Married segment check runs automatically during verify (not opt-in)
+- AC-5.5: Results include married segment flag on `DClassResult` or `SegmentVerification`
+
+### US-6: Enhanced booking script warnings
+
+As an RTW planner, I want the booking phone script to include through-flight and married segment warnings so the booking agent handles them correctly.
+
+**Acceptance Criteria**:
+- AC-6.1: Through-flight segments show via-stop info: "Through-flight via {stop} -- one segment, counts {continent}"
+- AC-6.2: Married segment risk segments show warning: "Married segment risk: {carrier} D-class may require connection through {hub}"
+- AC-6.3: Through-flight split warning: "Stopping over at {via} converts 1 segment to 2 + $125 reissue"
+- AC-6.4: GDS commands for through-flights book as single segment (no split)
+
+## Functional Requirements
+
+| ID | Requirement | Priority | Source |
+|----|-------------|----------|--------|
+| FR-1 | Add optional `via` field to Segment model: `Optional[str \| list[str]] = None` | Must | US-1 |
+| FR-2 | Pydantic validator normalizes `via` to `list[str]`, uppercases airport codes | Must | US-1 |
+| FR-3 | `build_context()` resolves via-stop continents, adds to `continents_visited` | Must | US-2 |
+| FR-4 | Via-stop continents tracked separately in ValidationContext (`via_continents` field) | Must | US-2 |
+| FR-5 | Via-stop does NOT affect `segments_per_continent` counts | Must | US-2 |
+| FR-6 | Create `rtw/data/through_flights.yaml` with known through-flights | Should | US-3 |
+| FR-7 | Create `rtw/rules/married.py` with static married segment pattern rules | Should | US-4 |
+| FR-8 | Register married segment rules in validator's `_discover_rules()` | Should | US-4 |
+| FR-9 | CX hub-connection married pattern detection | Should | US-4 |
+| FR-10 | QF standalone D-class married pattern detection | Should | US-4 |
+| FR-11 | Through-flight split detection (via-stop with stopover = reissue warning) | Should | US-4 |
+| FR-12 | ExpertFlyer paired query for married segment detection during verify | Could | US-5 |
+| FR-13 | `MarriedSegmentFlag` on verify results | Could | US-5 |
+| FR-14 | Booking script through-flight annotations | Should | US-6 |
+| FR-15 | Booking script married segment warnings | Should | US-6 |
+| FR-16 | GDS commands treat through-flights as single segment | Should | US-6 |
+| FR-17 | `rtw show` displays via-stop information when present | Should | US-1 |
+
+## Non-Functional Requirements
+
+| ID | Requirement | Category |
+|----|-------------|----------|
+| NFR-1 | All existing 1168+ tests pass with no changes (backward compatibility) | Compatibility |
+| NFR-2 | All 37+ YAML fixtures remain valid without modification | Compatibility |
+| NFR-3 | Through-flight data YAML loads in <10ms on startup | Performance |
+| NFR-4 | No mocks for API responses in tests; fixtures and real data only | Testing |
+| NFR-5 | Married segment ExpertFlyer queries respect existing rate limiting (5s between, 50/day soft limit) | Performance |
+| NFR-6 | New rule file follows `@register_rule` pattern exactly | Consistency |
+| NFR-7 | Via field Pydantic validator follows existing `field_validator` patterns in Segment model | Consistency |
+
+## Out of Scope
+
+- Auto-detection of through-flights from route data (user explicitly opted out)
+- Seasonal date ranges on through-flight data (separate concern)
+- Via field accepting airport names (IATA codes only)
+- Connection time threshold refinement (existing same-day check preserved)
+- Via stops contributing to per-continent segment limits
+- Auto-populating `via` from through-flight lookup table
+
+## Dependencies
+
+- Existing `get_continent()` in `rtw/continents.py` for via-stop continent resolution
+- Existing `@register_rule` decorator in `rtw/rules/base.py` for married segment rules
+- Existing `ExpertFlyerScraper` in `rtw/scraper/expertflyer.py` for paired queries
+- Existing `BookingGenerator` in `rtw/booking.py` for phone script warnings
+- Pydantic v2 `field_validator` for via field normalization
diff --git a/specs/via-through-flights/research.md b/specs/via-through-flights/research.md
new file mode 100644
index 0000000..c34d4e2
--- /dev/null
+++ b/specs/via-through-flights/research.md
@@ -0,0 +1,238 @@
+---
+spec: via-through-flights
+phase: research
+created: 2026-02-16
+---
+
+# Research: via-through-flights
+
+## Executive Summary
+
+Through-flights (single flight numbers with intermediate stops) are well-defined in aviation: they count as ONE segment but trigger continent counting at each stop. Adding a `via` field to the Segment model is technically straightforward (Optional[str | list[str]], backward compatible). Married segment detection is achievable via static pattern rules + ExpertFlyer live checks, though airline-specific married segment logic is not publicly documented in full and will require community knowledge + empirical validation.
+
+## External Research
+
+### Through-Flights: Definition and Rules
+
+A through-flight is "a direct flight between two points with no change in flight number. It may or may not include a stop at an intermediate point" (Qantas AgencyConnect). The FlyerTalk oneworld Explorer User Guide confirms: "a segment is a flight with a single flight number between two cities, whether or not it stops between the origin and destination."
+
+**Critical rule from Rule 3015 SS16**: "A continent is counted even if all you do is change planes there; even if your plane merely lands there." This means through-flight stops at intermediate airports trigger continent counting for pricing.
+
+**Sources**:
+| Source | Key Point |
+|--------|-----------|
+| [Qantas AgencyConnect](https://www.qantas.com/agencyconnect/us/en/policy-and-guidelines/book-and-service/through-flight-information.html) | Through-flight = single flight number, must be booked as one segment |
+| [FlyerTalk User Guide](https://www.flyertalk.com/forum/oneworld/2008084-oneworld-explorer-user-guide.html) | Segment = single flight number regardless of stops |
+| [FlyerTalk FAQs p174](https://www.flyertalk.com/forum/oneworld/338667-oneworld-explorer-ticket-faqs-174.html) | BA15 LHR-SYD is one segment containing 2 married sectors with SIN stop |
+| `01-fare-rules.md` SS16 (project root) | "Even technical plane stops count" for continent counting |
+
+### Known oneworld Through-Flights (Cross-Continent Stops)
+
+Compiled from flight tracking, airline sources, and community data:
+
+| Carrier | Flight | Route | Via Stop | Continents Crossed |
+|---------|--------|-------|----------|-------------------|
+| QF | QF1/QF2 | SYD-LHR | SIN | SWP -> **Asia** -> EU_ME |
+| QF | QF5/QF6 | SYD-ROM | PER | SWP (no cross-continent; PER is SWP) |
+| QF | QF3/QF4 | SYD-JFK | AKL | SWP -> SWP -> N_America (AKL is SWP, no extra continent) |
+| BA | BA15/BA16 | LHR-SYD | SIN | EU_ME -> **Asia** -> SWP |
+| QR | QR920/921 | DOH-ADL | SIN | EU_ME -> **Asia** -> SWP |
+| QR | QR908/909 | DOH-MEL | SIN | EU_ME -> **Asia** -> SWP |
+| CX | (various) | via HKG | HKG | Always Asia; CX hub is already in Asia |
+
+**Key observations**:
+1. SIN is the primary cross-continent via stop (Asia) for EU_ME <-> SWP flights
+2. PER and AKL stops are same-continent (SWP), so no extra continent triggered
+3. CX flights via HKG don't add extra continents since HKG is already in Asia
+4. QR flights DOH->SWP via SIN are the most impactful: they add Asia to continent count
+
+### Married Segments: Patterns and Detection
+
+**Definition**: Airlines link inventory on two or more flight segments so they must be booked/cancelled together. Availability may differ for married vs. individual segments.
+
+**Key patterns identified**:
+
+| Pattern | Carrier | Description | Detection Method |
+|---------|---------|-------------|-----------------|
+| Hub connection lock | CX | D-class often only available when connecting through HKG (not as standalone segment) | Static: CX segment where from/to != HKG, but itinerary has CX HKG connection |
+| Stingy standalone D | QF | D-class rare on long-haul as standalone; more available as married with domestic connection | Static: QF transoceanic segment without QF domestic connection |
+| H-class fallback | AA | Uses H class (not D) for OWE business; POS/POO rules affect availability | Static: AA segments (already handled in carriers.yaml) |
+| Same-day connection | Any | Transit segments on same day have married segment risk | Static: transit + same date on consecutive segments (already in booking.py) |
+| Through-flight split | BA/QF | BA15 LHR-SYD is married at SIN; splitting to stopover in SIN requires reissue ($125) | Static: known through-flight with via stop where user wants stopover |
+
+**Sources**:
+| Source | Key Point |
+|--------|-----------|
+| [One Mile at a Time](https://onemileatatime.com/guides/airline-married-segment/) | Married segments = linked inventory; common on CX, QR, AA, Lufthansa |
+| [AwardFares Blog](https://blog.awardfares.com/married-segments/) | Cannot "divorce" segments after booking |
+| [ATPA (ANA)](https://atpa.fly-ana.com/ticketing-and-policies/married-segment-control) | Rules and penalties on married segment control |
+| [AA SalesLink](https://saleslink.aa.com/en-US/resources/html/ticketing-information.html) | AA shifted to Point of Commencement availability (Aug 2024) |
+
+### ExpertFlyer Capabilities for Married Segment Detection
+
+ExpertFlyer can detect married segment patterns indirectly:
+- **Connection search**: Shows availability for connecting itineraries (married inventory)
+- **Individual search**: Shows standalone segment availability
+- **Comparison**: If connecting shows D9 but individual shows D0, that's a married segment signal
+- **Limitation**: Flight Alerts are per-segment only; cannot alert on married availability
+- **Limitation**: Cannot directly query "is this a married segment?"
+
+**Source**: [ExpertFlyer User Guide](https://www.expertflyer.com/media/user-guide.pdf)
+
+### Pitfalls to Avoid
+
+1. **Auto-detection temptation**: User explicitly said they do NOT want through-flight auto-detection from route data. Only explicit `via` field triggers continent counting.
+2. **Over-counting continents**: A via stop in the same continent as origin/destination should NOT add a new continent (e.g., QF5 via PER — both SWP).
+3. **Married segment false positives**: Not all connections are married. Many carriers offer standalone D-class on long-haul.
+4. **Stale through-flight data**: Airlines change through-flight routes seasonally. Data should be easy to update.
+
+## Codebase Analysis
+
+### Existing Patterns
+
+**Segment model** (`rtw/models.py:131-163`): Pydantic v2 BaseModel with `model_config = {"populate_by_name": True}`. Uses `Field(alias="from")` for `from_airport`. Adding optional `via` field follows exact same pattern.
+
+**Continent detection** (`rtw/validator.py:141-175`): `_detect_implicit_continents()` already handles EU_ME <-> SWP phantom Asia detection. Via-stop continent counting should supplement (not replace) this, since the implicit rule handles the nonstop EU_ME-SWP case (e.g., QF Perth-London nonstop still counts Asia).
+
+**Booking warnings** (`rtw/booking.py:179-196`): Already has basic married segment detection for same-day transit connections. This is the natural extension point for richer married segment warnings.
+
+**Data pattern** (`rtw/data/`): YAML files for carriers, fares, continents, hubs, same_cities. Through-flight lookup data fits this pattern perfectly as `rtw/data/through_flights.yaml`.
+
+**ExpertFlyer scraper** (`rtw/scraper/expertflyer.py`): Queries by O&D pair + date + carrier. To detect married segments, would need to compare results of `check_availability(A, C)` (direct) vs checking `check_availability(A, B)` + `check_availability(B, C)` (via hub). This is a meaningful extension but doubles query count.
+
+**Verify models** (`rtw/verify/models.py`): `FlightAvailability` already has a `stops` field. Results already distinguish nonstop vs connecting flights. `DClassResult.connection_only_segments` property exists.
+
+### Dependencies
+
+- **Pydantic v2**: Optional field with union type is straightforward: `via: Optional[str | list[str]] = None`
+- **YAML parsing**: Existing YAML loader in `rtw/continents.py` can be used as pattern for through-flight data
+- **airportsdata**: Already used for continent lookup; via-stop airports would use same `get_continent()` function
+- **ExpertFlyer integration**: Already operational; extension needs new query patterns, not new auth/session logic
+
+### Constraints
+
+1. **Backward compatibility**: All existing YAML fixtures (37 files) lack `via` field. Optional field with `None` default ensures zero breakage.
+2. **Test count**: 1168+ tests. New features must not break existing tests.
+3. **No mocks for API responses**: Tests use real fixtures. Through-flight data should be testable with YAML fixtures.
+4. **Rule engine architecture**: Rules are separate files registered via decorator. New married segment rules should follow this pattern.
+
+### Related Specs
+
+| Spec | Relevance | mayNeedUpdate |
+|------|-----------|---------------|
+| 002-dclass-verify | **High** — ExpertFlyer integration directly relevant; married segment detection extends verify logic | true |
+| 004-route-builder | **Medium** — `rtw build` may want to suggest through-flights; `rtw scan-dates` could check married availability | false |
+| 001-rtw-optimizer | **Low** — Core validator; this spec extends it but doesn't change fundamentals | false |
+| 003-nonstop-preverify | **Low** — Nonstop check; tangential to through-flight/married segment work | false |
+
+## Quality Commands
+
+| Type | Command | Source |
+|------|---------|--------|
+| Lint | `ruff check rtw/ tests/` | CLAUDE.md |
+| Unit Test | `uv run pytest` | pyproject.toml / CLAUDE.md |
+| Unit Test (fast) | `uv run pytest -m "not slow and not integration"` | CI config |
+| Build | N/A (pure Python, no build step) | - |
+| TypeCheck | Not configured | - |
+
+**Local CI**: `ruff check rtw/ tests/ && uv run pytest -m "not slow and not integration"`
+
+## Feasibility Assessment
+
+| Aspect | Assessment | Notes |
+|--------|------------|-------|
+| Via field on Segment | **High** — trivial Pydantic change | Optional field, fully backward compatible |
+| Continent counting from via | **High** — extends existing `_detect_implicit_continents()` | Clear algorithm: resolve via-stop continent, add to visited |
+| Through-flight lookup YAML | **High** — follows existing data pattern | YAML file with carrier/flight/via mappings |
+| Married segment static rules | **High** — extends existing booking.py warnings | Pattern-based: CX/HKG, QF standalone, same-day transit |
+| ExpertFlyer married segment detection | **Medium** — requires paired queries | Doubles query count; rate limiting concern; empirical validation needed |
+| Booking script enhancements | **High** — natural extension of existing warning system | Add through-flight notes and married segment warnings to phone script |
+
+| Aspect | Assessment | Notes |
+|--------|------------|-------|
+| Technical Viability | **High** | All components extend existing architecture cleanly |
+| Effort Estimate | **M** | ~20-30 tasks across model, validator, data, booking, tests |
+| Risk Level | **Low-Medium** | Main risk: married segment patterns are empirical, not documented by airlines |
+
+## Recommendations for Requirements
+
+1. **Via field type**: Use `Optional[str | list[str]] = None`. Single string for common case (one via stop), list for rare multi-stop (QF1 SYD-SIN-LHR has one via, but future routes could have two). Validator normalizes to list internally.
+
+2. **Supplement, don't replace implicit detection**: The existing EU_ME <-> SWP implicit Asia rule handles nonstop flights (e.g., QF PER-LHR). Via-stop counting handles through-flights. Both are needed — they cover different cases.
+
+3. **YAML data file for through-flights**: Create `rtw/data/through_flights.yaml` with structure:
+ ```yaml
+ QF1:
+ carrier: QF
+ from: SYD
+ via: [SIN]
+ to: LHR
+ continents_added: [Asia]
+ ```
+ This is reference data only — validator uses explicit `via` field on segments, not auto-lookup.
+
+4. **Married segment rules as a new rule file**: Create `rtw/rules/married.py` with `MarriedSegmentRule` registered in the rule engine. This keeps it alongside other rules and follows the existing pattern.
+
+5. **ExpertFlyer married detection as opt-in**: When ExpertFlyer credentials are configured and `--check-married` flag is passed to `rtw verify`, run paired queries (direct vs. connecting) to detect married segment availability differences. Do NOT make this the default due to doubled query count.
+
+6. **Booking script warnings**: Add three new warning types:
+ - "Through-flight: {route} via {stop} — one segment, counts {continent} for pricing"
+ - "Married segment risk: {carrier} D-class may require connection through {hub}"
+ - "Through-flight split: Stopping over at {via} converts one segment to two + $125 reissue fee"
+
+## Open Questions
+
+1. **Via field in YAML — should it also accept airport names?** Currently the model uses 3-letter IATA codes. Should `via: Singapore` be accepted or only `via: SIN`?
+
+2. **Multiple through-flight stops**: QF5 SYD-PER-ROM has one via stop. Are there any oneworld through-flights with 2+ intermediate stops? Research found none currently, but the data model should support it.
+
+3. **Through-flight data maintenance**: Should the YAML lookup be versioned by date range (seasonal routes like QF5/6 only operate May-Sep)? Or is a simple static list sufficient?
+
+4. **Married segment severity**: Should married segment warnings be `WARNING` or `INFO`? They don't make the ticket invalid, but they affect bookability.
+
+5. **Connection time threshold**: The existing same-day transit check in `booking.py` uses `seg.date == nxt.date`. Should this be refined to use actual time analysis (e.g., <4 hours = likely married)?
+
+## Sources
+
+- [Qantas AgencyConnect: Through Flight Information](https://www.qantas.com/agencyconnect/us/en/policy-and-guidelines/book-and-service/through-flight-information.html)
+- [FlyerTalk: oneworld Explorer User Guide](https://www.flyertalk.com/forum/oneworld/2008084-oneworld-explorer-user-guide.html)
+- [FlyerTalk: oneworld Explorer FAQs p174](https://www.flyertalk.com/forum/oneworld/338667-oneworld-explorer-ticket-faqs-174.html)
+- [One Mile at a Time: Married Segments Guide](https://onemileatatime.com/guides/airline-married-segment/)
+- [AwardFares: Married Segments Guide](https://blog.awardfares.com/married-segments/)
+- [AA SalesLink: Ticketing Information](https://saleslink.aa.com/en-US/resources/html/ticketing-information.html)
+- [Australian Frequent Flyer: oneworld Explorer Guide](https://www.australianfrequentflyer.com.au/oneworld-explorer-rtw-guide/)
+- [Point Hacks: QF1 Guide](https://www.pointhacks.com.au/guides/flights/qantas-qf1/)
+- [Point Hacks: QF3 Guide](https://www.pointhacks.com.au/guides/flights/qantas-qf3/)
+- [Australian Frequent Flyer: QF5 Guide](https://www.australianfrequentflyer.com.au/qf5/)
+- [ExpertFlyer User Guide](https://www.expertflyer.com/media/user-guide.pdf)
+- [Pydantic v2 Fields Documentation](https://docs.pydantic.dev/latest/concepts/fields/)
+- Codebase: `rtw/models.py`, `rtw/validator.py`, `rtw/rules/geography.py`, `rtw/booking.py`, `rtw/continents.py`, `rtw/scraper/expertflyer.py`, `rtw/verify/models.py`
+
+---QUESTIONS FOR USER---
+
+1. **Should the through-flight YAML include seasonal date ranges?**
+ - Why: QF5/6 SYD-PER-ROM only operates May-September. Static data without dates could show stale routes.
+ - Options: A) Simple static list (carrier/flight/via/route) B) Add `season: [May, Jun, Jul, Aug, Sep]` field C) Add `active_from/active_to` date fields
+ - Recommend: A — simpler, and the data is reference-only (user puts `via` on their actual segments). Seasonal awareness is a separate concern for route search.
+
+2. **What severity level for married segment warnings?**
+ - Why: Married segments don't violate Rule 3015 but affect practical bookability.
+ - Options: A) `WARNING` (yellow, prominent) B) `INFO` (blue, informational) C) New severity `BOOKING_RISK` (separate from validation)
+ - Recommend: B (INFO) for static pattern detection, A (WARNING) for ExpertFlyer-confirmed married segments. No new severity level — adds complexity without clear benefit.
+
+3. **Should `rtw verify --check-married` compare direct vs. connecting availability automatically?**
+ - Why: This doubles ExpertFlyer queries (2x per segment for connection hubs). Rate limiting + daily soft limit (50 queries) is a concern.
+ - Options: A) Always check married when verifying B) `--check-married` opt-in flag C) Skip ExpertFlyer married detection entirely, rely on static rules only
+ - Recommend: B — opt-in flag. Most users want basic D-class check; married detection is for advanced optimization.
+
+4. **Should the `via` field accept a single string or always require a list?**
+ - Why: 99% of through-flights have exactly one via stop. Requiring `via: [SIN]` for every case is verbose.
+ - Options: A) `str | list[str]` (accept both, normalize to list) B) Always `list[str]` C) Always `str` (single via stop only)
+ - Recommend: A — accept both for user convenience, normalize internally. Pydantic v2 supports this with a validator.
+
+5. **How should via-stop continents interact with per-continent segment limits?**
+ - Why: A via stop counts the continent as "visited" for pricing, but the through-flight is still ONE segment. Should it count toward the via-continent's segment limit?
+ - Options: A) Via stop adds continent to visited list but does NOT count toward per-continent segment limit B) Via stop counts as a segment in that continent C) Via stop counts as 0.5 segments
+ - Recommend: A — the through-flight is one segment (Rule 3015 is clear on this). The via stop affects pricing/continent count only, not segment limits.
+
+---END QUESTIONS---
diff --git a/specs/via-through-flights/tasks.md b/specs/via-through-flights/tasks.md
new file mode 100644
index 0000000..46c65c4
--- /dev/null
+++ b/specs/via-through-flights/tasks.md
@@ -0,0 +1,133 @@
+---
+spec: via-through-flights
+phase: tasks
+total_tasks: 13
+created: 2026-02-16
+generated: auto
+---
+
+# Tasks: via-through-flights
+
+## Phase 1: Make It Work (POC)
+
+Focus: Via field on model + continent counting in validator. End-to-end proof that `via: SIN` on a segment causes Asia to appear in `continents_visited`.
+
+- [ ] 1.1 Add `via` field to Segment model with normalization
+ - **Do**: In `rtw/models.py`, add `via: Optional[str | list[str]] = None` to the `Segment` class. Add a `@field_validator("via", mode="before")` that normalizes: `None` -> `None`, `str` -> `[str.upper()]`, `list` -> `[x.upper() for x in list]`. Add `has_via` property (returns `bool`) and `via_airports` property (returns `self.via or []`).
+ - **Files**: `rtw/models.py`
+ - **Done when**: `Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"})` produces `via=["SIN"]`. `Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF"})` produces `via=None`. `Segment(**{"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["sin", "kul"]})` produces `via=["SIN", "KUL"]`.
+ - **Verify**: `uv run pytest tests/test_models.py -x`
+ - **Commit**: `feat(models): add via field to Segment for through-flight stops`
+ - _Requirements: FR-1, FR-2_
+ - _Design: Component A_
+
+- [ ] 1.2 Extend ValidationContext and build_context() for via-continent counting
+ - **Do**: In `rtw/validator.py`, add two new fields to `ValidationContext`: `via_continents: list[Continent]` and `via_continent_segments: dict[Continent, list[tuple[int, str]]]` (both with `default_factory`). In `build_context()`, after the main segment loop and BEFORE `_detect_implicit_continents()`, add a second loop over segments: for each segment's `via_airports`, call `get_continent(via_apt)`. If continent found and not in `seen_continents`, append it. Track in `via_continent_segments` as `(segment_index, via_airport)`. Assign to context fields.
+ - **Files**: `rtw/validator.py`
+ - **Done when**: An itinerary with segment `SYD->LHR via SIN` has `Asia` in `ctx.continents_visited` and `ctx.via_continents == [Continent.ASIA]`. An itinerary with `SYD->FCO via PER` does NOT add a new continent (PER is SWP, already counted).
+ - **Verify**: `uv run pytest tests/test_validator.py -x`
+ - **Commit**: `feat(validator): count via-stop continents in build_context`
+ - _Requirements: FR-3, FR-4, FR-5_
+ - _Design: Component B_
+
+- [ ] 1.3 POC Checkpoint — end-to-end via continent counting
+ - **Do**: Create test fixture `tests/fixtures/via_through_flight.yaml` with a DONE4 itinerary that includes a QF SYD-LHR segment with `via: SIN`. Verify that full validation counts Asia as visited, via-stop does NOT increment segments_per_continent for Asia, and existing tests still pass. Create `tests/test_rules/test_via_counting.py` with 3-5 targeted tests.
+ - **Files**: `tests/fixtures/via_through_flight.yaml`, `tests/test_rules/test_via_counting.py`
+ - **Done when**: New fixture validates successfully. Asia appears in continents_visited. `segments_per_continent` for Asia is 0 (or whatever the non-via count is). All existing tests pass.
+ - **Verify**: `uv run pytest -x` (full suite)
+ - **Commit**: `feat(via): complete POC — via field with continent counting`
+ - _Requirements: AC-2.1 through AC-2.6_
+ - _Design: Components A, B_
+
+## Phase 2: Core Features
+
+After POC validated, build out remaining features: through-flight data, married segment rules, booking warnings.
+
+- [ ] 2.1 Create through-flight reference data YAML
+ - **Do**: Create `rtw/data/through_flights.yaml` with the known through-flights from research: QF1/2 (SYD-SIN-LHR), BA15/16 (LHR-SIN-SYD), QR920/921 (DOH-SIN-ADL), QR908/909 (DOH-SIN-MEL), QF5/6 (SYD-PER-FCO, no continent impact), QF3/4 (SYD-AKL-JFK, no continent impact). Follow schema from design: carrier, flights (list), from, to, via (list), continents_added (list), notes (optional string). Add a loader function in a new `rtw/through_flights.py` module (or inline in data loading).
+ - **Files**: `rtw/data/through_flights.yaml`, optionally `rtw/through_flights.py`
+ - **Done when**: YAML file exists and is loadable with `yaml.safe_load()`. Data has at least 6 entries. Tests for data integrity (carrier codes valid, airport codes 3-letter).
+ - **Verify**: `python3 -c "import yaml; d=yaml.safe_load(open('rtw/data/through_flights.yaml')); print(len(d['through_flights']), 'through-flights loaded')"`
+ - **Commit**: `feat(data): add known through-flights reference YAML`
+ - _Requirements: FR-6, AC-3.1 through AC-3.4_
+ - _Design: Component C_
+
+- [ ] 2.2 Create married segment rules file
+ - **Do**: Create `rtw/rules/married.py` with `@register_rule` class `MarriedSegmentRule`. Implement three checks: (1) CX hub-connection — CX segments where neither endpoint is HKG, warn about married risk through HKG; (2) Through-flight split — segment has `via` stop and that via-stop city appears as a stopover destination elsewhere in the itinerary, warn about $125 reissue; (3) Return INFO pass result if no issues. All warnings use `Severity.INFO`. Register the module in `rtw/validator.py:_discover_rules()` by adding `import rtw.rules.married`.
+ - **Files**: `rtw/rules/married.py`, `rtw/validator.py` (one import line)
+ - **Done when**: Married rule appears in `get_registered_rules()`. CX segment NRT-SIN on a CX-heavy itinerary triggers INFO warning. Through-flight via SIN with SIN stopover elsewhere triggers split warning. Itinerary with no CX and no via fields gets "No married segment risks detected."
+ - **Verify**: `uv run pytest tests/test_validator.py -x && uv run pytest -x`
+ - **Commit**: `feat(rules): add married segment pattern detection`
+ - _Requirements: FR-7, FR-8, FR-9, FR-11, AC-4.1 through AC-4.6_
+ - _Design: Component D_
+
+- [ ] 2.3 Enhance booking script with through-flight and married segment warnings
+ - **Do**: In `rtw/booking.py:_segment_scripts()`, add through-flight annotation block: if `seg.has_via`, resolve via-stop continents, add warning string, and append via info to phone instruction. For GDS commands in `_gds_commands()`, through-flights should be booked as single segment (already the case — no change needed, but add a comment). Enhance existing married segment warning text with carrier-specific context when carrier is CX (mention HKG hub).
+ - **Files**: `rtw/booking.py`
+ - **Done when**: Booking script for QF SYD-LHR with `via: SIN` includes "Through-flight via SIN" in phone instructions and warnings. GDS commands still show single SS command for the segment.
+ - **Verify**: `uv run pytest tests/test_booking.py -x`
+ - **Commit**: `feat(booking): add through-flight and married segment warnings`
+ - _Requirements: FR-14, FR-15, FR-16, AC-6.1 through AC-6.4_
+ - _Design: Component E_
+
+- [ ] 2.4 Add married_segment_note to SegmentVerification and detect in verifier
+ - **Do**: In `rtw/verify/models.py`, add `married_segment_note: Optional[str] = None` to `SegmentVerification`. In `rtw/verify/verifier.py`, after checking direct availability for a segment, check if the carrier is in `_MARRIED_CHECK_HUBS` dict (`{"CX": "HKG", "QR": "DOH"}`). If direct result has `seats == 0` but `flights` list contains connecting flights with seats > 0, set `married_segment_note` on the SegmentVerification. This uses existing ExpertFlyer results (no extra queries needed for the basic version).
+ - **Files**: `rtw/verify/models.py`, `rtw/verify/verifier.py`
+ - **Done when**: SegmentVerification has `married_segment_note` field. CX segment with 0 nonstop D-class but connecting D-class available gets note "D-class only via connection (likely married through HKG)".
+ - **Verify**: `uv run pytest tests/test_verify_models.py -x`
+ - **Commit**: `feat(verify): detect married segment patterns from ExpertFlyer results`
+ - _Requirements: FR-12, FR-13, AC-5.1, AC-5.2, AC-5.5_
+ - _Design: Component F_
+
+## Phase 3: Testing
+
+- [ ] 3.1 Unit tests for via field and continent counting
+ - **Do**: In `tests/test_models.py`, add tests for: via=None (default), via="sin" (normalized to ["SIN"]), via=["sin","kul"] (normalized to ["SIN","KUL"]), via_airports property, has_via property, invalid via (non-string elements). In `tests/test_rules/test_via_counting.py`, add tests for: via-stop adds continent, via-stop same continent no effect, multiple via stops, via-stop does NOT increment segments_per_continent, via-stop combined with implicit Asia rule (both should work independently).
+ - **Files**: `tests/test_models.py`, `tests/test_rules/test_via_counting.py`
+ - **Done when**: At least 10 new tests pass covering all acceptance criteria from US-1 and US-2.
+ - **Verify**: `uv run pytest tests/test_models.py tests/test_rules/test_via_counting.py -v`
+ - **Commit**: `test(via): add unit tests for via field and continent counting`
+ - _Requirements: AC-1.1 through AC-1.5, AC-2.1 through AC-2.6_
+
+- [ ] 3.2 Unit tests for married segment rules
+ - **Do**: Create `tests/test_rules/test_married.py`. Test scenarios: (1) CX segment not through HKG triggers INFO warning, (2) CX segment through HKG does NOT trigger warning, (3) through-flight split: segment with via SIN + SIN stopover elsewhere triggers warning, (4) through-flight no split: via SIN but no SIN stopover, (5) no CX no via = pass result, (6) multiple CX segments produce multiple warnings.
+ - **Files**: `tests/test_rules/test_married.py`
+ - **Done when**: At least 6 tests covering all married segment patterns pass.
+ - **Verify**: `uv run pytest tests/test_rules/test_married.py -v`
+ - **Commit**: `test(married): add unit tests for married segment rules`
+ - _Requirements: AC-4.1 through AC-4.6_
+
+- [ ] 3.3 Unit tests for booking script through-flight warnings
+ - **Do**: In `tests/test_booking.py`, add tests for: (1) segment with via produces through-flight warning in phone script, (2) segment with via shows via info in instruction text, (3) segment without via has no through-flight warning, (4) GDS command for via segment is single SS entry (not split).
+ - **Files**: `tests/test_booking.py`
+ - **Done when**: At least 4 new booking tests pass.
+ - **Verify**: `uv run pytest tests/test_booking.py -v`
+ - **Commit**: `test(booking): add through-flight warning tests`
+ - _Requirements: AC-6.1 through AC-6.4_
+
+- [ ] 3.4 Integration test with through-flight itinerary
+ - **Do**: In `tests/test_integration.py` or `tests/test_new_fixtures.py`, add a test that loads the `via_through_flight.yaml` fixture, runs full validation, and asserts: (1) validation passes, (2) continents_visited includes the via-stop continent, (3) married segment rule produces results, (4) booking script includes through-flight annotations.
+ - **Files**: `tests/test_integration.py` or `tests/test_new_fixtures.py`
+ - **Done when**: Integration test exercises full pipeline: YAML -> model -> validator -> rules -> booking for a through-flight itinerary.
+ - **Verify**: `uv run pytest tests/test_integration.py -v -k via`
+ - **Commit**: `test(integration): add through-flight end-to-end test`
+
+## Phase 4: Quality Gates
+
+- [ ] 4.1 Local quality check
+ - **Do**: Run full test suite, lint check, and verify no regressions. Ensure all 1168+ existing tests still pass plus new tests. Run `ruff check` on all modified files.
+ - **Verify**: `ruff check rtw/ tests/ && uv run pytest -x`
+ - **Done when**: All commands pass with 0 errors. Total test count has increased.
+ - **Commit**: `fix(via): address lint/type issues` (if needed)
+
+- [ ] 4.2 Create PR and verify CI
+ - **Do**: Push branch, create PR with gh CLI. PR title: "feat: add via field for through-flight stops + married segment detection". PR body: summary of changes, link to spec, test plan.
+ - **Verify**: `gh pr checks --watch` all green
+ - **Done when**: PR ready for review with all CI checks passing.
+
+## Notes
+
+- **POC shortcuts taken**: Phase 1 skips married rules, booking warnings, and ExpertFlyer integration. Just model + validator.
+- **Production TODOs**: ExpertFlyer paired query for married detection uses existing connection results (no extra queries). Full paired query implementation deferred if basic detection is sufficient.
+- **Backward compatibility**: All changes are additive. `via: null` default means zero impact on existing YAML files.
+- **Rule count**: After adding married.py, total rules will be 25 across 11 files.
diff --git a/tests/fixtures/via_through_flight.yaml b/tests/fixtures/via_through_flight.yaml
new file mode 100644
index 0000000..ec35c6d
--- /dev/null
+++ b/tests/fixtures/via_through_flight.yaml
@@ -0,0 +1,66 @@
+# Through-flight via-stop test: DONE5 business from LHR
+# Tests that a through-flight via: SIN counts Asia from via stop
+# without consuming an extra segment.
+#
+# Route: LHR -> NRT -> SYD (via SIN) -> LAX -> GRU -> JFK -> LHR (eastbound)
+# Key: NRT-SYD segment has via: SIN (through-flight technical stop in Singapore)
+# Continents: EU_ME, Asia (NRT + reinforced by via SIN), SWP, N_America, S_America = 5
+# Via-stop does NOT add to segments_per_continent for Asia
+# 6 segments, all within limits
+
+ticket:
+ type: DONE5
+ cabin: business
+ origin: LHR
+ passengers: 1
+ departure: "2026-04-01"
+
+segments:
+ # EU_ME -> Asia (TC2 -> TC3)
+ - from: LHR
+ to: NRT
+ carrier: JL
+ date: "2026-04-01"
+ type: stopover
+ notes: "JAL to Tokyo"
+
+ # Asia -> SWP (within TC3) — through-flight via SIN
+ - from: NRT
+ to: SYD
+ carrier: QF
+ date: "2026-04-05"
+ type: stopover
+ via: SIN
+ notes: "Qantas to Sydney — through-flight via Singapore"
+
+ # SWP -> N_America (TC3 -> TC1 — Pacific crossing)
+ - from: SYD
+ to: LAX
+ carrier: QF
+ date: "2026-04-10"
+ type: stopover
+ notes: "Qantas A380 — Pacific crossing"
+
+ # N_America -> S_America (within TC1)
+ - from: LAX
+ to: GRU
+ carrier: AA
+ date: "2026-04-15"
+ type: stopover
+ notes: "AA to Sao Paulo"
+
+ # S_America -> N_America (within TC1)
+ - from: GRU
+ to: JFK
+ carrier: AA
+ date: "2026-04-20"
+ type: stopover
+ notes: "AA return to JFK"
+
+ # N_America -> EU_ME (TC1 -> TC2 — Atlantic crossing)
+ - from: JFK
+ to: LHR
+ carrier: BA
+ date: "2026-04-25"
+ type: final
+ notes: "BA Club World — Atlantic crossing, return to origin"
diff --git a/tests/test_booking.py b/tests/test_booking.py
index b7c9d08..b46594d 100644
--- a/tests/test_booking.py
+++ b/tests/test_booking.py
@@ -195,6 +195,92 @@ def test_mex_mad_ib_also_flagged(self, generator, v3):
assert len(ib_warnings) >= 1
+class TestThroughFlightWarnings:
+ """Test through-flight via-stop annotations in booking script."""
+
+ def test_via_produces_through_flight_warning(self, generator):
+ """Segment with via produces through-flight warning."""
+ itin = Itinerary(
+ ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"},
+ segments=[
+ {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN"},
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "LAX", "carrier": "JL"},
+ {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"},
+ ],
+ )
+ scripts = generator._segment_scripts(itin)
+ via_warnings = [w for w in scripts[0].warnings if "Through-flight" in w]
+ assert len(via_warnings) == 1
+ assert "SIN" in via_warnings[0]
+ assert "single segment" in via_warnings[0]
+
+ def test_via_shows_in_phone_instruction(self, generator):
+ """Via info appears in phone instruction text."""
+ itin = Itinerary(
+ ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"},
+ segments=[
+ {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN"},
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "LAX", "carrier": "JL"},
+ {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"},
+ ],
+ )
+ scripts = generator._segment_scripts(itin)
+ assert "Through-flight via: SIN" in scripts[0].phone_instruction
+
+ def test_no_via_no_through_flight_warning(self, generator):
+ """Segment without via has no through-flight warning."""
+ itin = Itinerary(
+ ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"},
+ segments=[
+ {"from": "SYD", "to": "LHR", "carrier": "QF"},
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "LAX", "carrier": "JL"},
+ {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"},
+ ],
+ )
+ scripts = generator._segment_scripts(itin)
+ via_warnings = [w for w in scripts[0].warnings if "Through-flight" in w]
+ assert len(via_warnings) == 0
+
+ def test_gds_via_segment_single_entry(self, generator):
+ """GDS command for via segment is a single SS entry (not split)."""
+ itin = Itinerary(
+ ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"},
+ segments=[
+ {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN",
+ "flight": "QF1", "date": "2026-04-01"},
+ {"from": "LHR", "to": "NRT", "carrier": "JL",
+ "date": "2026-04-05"},
+ {"from": "NRT", "to": "LAX", "carrier": "JL",
+ "date": "2026-04-10"},
+ {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final",
+ "date": "2026-04-15"},
+ ],
+ )
+ gds = generator._gds_commands(itin)
+ # First SS command should be SYD-LHR, not SYD-SIN + SIN-LHR
+ ss_commands = [c for c in gds if c.startswith("SS")]
+ assert any("SYDLHR" in c for c in ss_commands)
+ assert not any("SYDSIN" in c for c in ss_commands)
+
+ def test_cx_booking_warning(self, generator):
+ """CX segment not through HKG triggers D-class married warning."""
+ itin = Itinerary(
+ ticket={"type": "DONE4", "cabin": "business", "origin": "LHR"},
+ segments=[
+ {"from": "LHR", "to": "NRT", "carrier": "CX"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ],
+ )
+ scripts = generator._segment_scripts(itin)
+ cx_warnings = [w for w in scripts[0].warnings if "CX" in w and "HKG" in w]
+ assert len(cx_warnings) == 1
+
+
# --- T033: GDS commands ---
diff --git a/tests/test_models.py b/tests/test_models.py
index ee7b980..c30d983 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -101,6 +101,34 @@ def test_default_type_is_stopover(self):
s = Segment(**{"from": "CAI", "to": "AMM"})
assert s.type == SegmentType.STOPOVER
+ def test_via_default_none(self):
+ s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF"})
+ assert s.via is None
+ assert s.has_via is False
+ assert s.via_airports == []
+
+ def test_via_string_normalized(self):
+ s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"})
+ assert s.via == ["SIN"]
+ assert s.has_via is True
+ assert s.via_airports == ["SIN"]
+
+ def test_via_list_normalized(self):
+ s = Segment(**{"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["sin", "kul"]})
+ assert s.via == ["SIN", "KUL"]
+ assert s.has_via is True
+ assert s.via_airports == ["SIN", "KUL"]
+
+ def test_via_empty_list(self):
+ s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": []})
+ assert s.has_via is False
+ assert s.via_airports == []
+
+ def test_via_preserved_in_model_dump(self):
+ s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"})
+ d = s.model_dump(mode="json")
+ assert d["via"] == ["SIN"]
+
# --- Itinerary Tests ---
diff --git a/tests/test_rules/test_fixture_validation.py b/tests/test_rules/test_fixture_validation.py
index b5208e5..54bf530 100644
--- a/tests/test_rules/test_fixture_validation.py
+++ b/tests/test_rules/test_fixture_validation.py
@@ -329,3 +329,36 @@ def test_rule_implicit_asia(self, validator):
# SWP, EU_ME, N_America, Asia (implicit or explicit via NRT)
continent_results = _results_by_rule(report, "continent_count")
assert all(r.passed for r in continent_results)
+
+
+# ---------------------------------------------------------------------------
+# Via Through-Flight Fixtures
+# ---------------------------------------------------------------------------
+
+class TestViaFixtures:
+ """Tests for through-flight via-stop counting."""
+
+ def test_via_through_flight_full_pipeline(self, validator):
+ """via_through_flight.yaml: DONE5 with via: SIN on NRT-SYD.
+ Asia counted from NRT segment + reinforced by via SIN.
+ Married segment rule produces result (no CX, no via-split risk).
+ 5 continents -> DONE5 passes continent count.
+ """
+ report = validator.validate(_load("via_through_flight.yaml"))
+
+ # Continent count passes (5 continents)
+ continent_results = _results_by_rule(report, "continent_count")
+ assert all(r.passed for r in continent_results)
+
+ # Married segment rule fires (pass = no risks detected)
+ married_results = _results_by_rule(report, "married_segment")
+ assert len(married_results) >= 1
+ # No CX, no via-split risk -> should pass
+ assert all(r.passed for r in married_results)
+
+ # Return to origin: LHR->LHR
+ return_results = _results_by_rule(report, "return_to_origin")
+ assert all(r.passed for r in return_results)
+
+ # Overall should pass (no violations)
+ assert report.passed
diff --git a/tests/test_rules/test_married.py b/tests/test_rules/test_married.py
new file mode 100644
index 0000000..04239ec
--- /dev/null
+++ b/tests/test_rules/test_married.py
@@ -0,0 +1,100 @@
+"""Tests for MarriedSegmentRule — CX hub-connection and through-flight split detection."""
+
+from rtw.models import Itinerary, Ticket, Segment, Severity
+from rtw.rules.married import MarriedSegmentRule
+
+
+def _make_itinerary(segments_data, origin="LHR", ticket_type="DONE4"):
+ ticket = Ticket(type=ticket_type, cabin="business", origin=origin)
+ segments = [Segment(**s) for s in segments_data]
+ return Itinerary(ticket=ticket, segments=segments)
+
+
+class TestCXHubConnection:
+ def test_cx_not_through_hkg_warns(self):
+ """CX NRT-SIN (neither is HKG) triggers married segment INFO."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SIN", "carrier": "CX"}, # Neither is HKG
+ {"from": "SIN", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ info = [r for r in results if not r.passed and r.severity == Severity.INFO]
+ assert len(info) >= 1
+ assert "CX" in info[0].message
+ assert "HKG" in info[0].message
+
+ def test_cx_through_hkg_no_warning(self):
+ """CX HKG-SYD (one endpoint is HKG) does NOT trigger warning."""
+ segs = [
+ {"from": "LHR", "to": "HKG", "carrier": "CX"},
+ {"from": "HKG", "to": "SYD", "carrier": "CX"}, # HKG is endpoint
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ cx_warnings = [r for r in results if not r.passed and "CX" in r.message]
+ assert len(cx_warnings) == 0
+
+ def test_multiple_cx_segments(self):
+ """Multiple CX segments not through HKG produce multiple warnings."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "CX"}, # Neither is HKG
+ {"from": "NRT", "to": "SIN", "carrier": "CX"}, # Neither is HKG
+ {"from": "SIN", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ cx_warnings = [r for r in results if not r.passed and "CX" in r.message]
+ assert len(cx_warnings) == 2
+
+
+class TestThroughFlightSplit:
+ def test_via_stop_city_as_stopover_warns(self):
+ """Via SIN + SIN as stopover destination triggers split warning."""
+ segs = [
+ {"from": "LHR", "to": "SIN", "carrier": "BA"}, # Stopover in SIN
+ {"from": "SIN", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"}, # Via SIN!
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ split_warnings = [r for r in results if not r.passed and "through-flight" in r.message]
+ assert len(split_warnings) == 1
+ assert "SIN" in split_warnings[0].message
+
+ def test_via_stop_no_matching_stopover_ok(self):
+ """Via SIN but no SIN stopover elsewhere — no split warning."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ split_warnings = [r for r in results if not r.passed and "through-flight" in r.message]
+ assert len(split_warnings) == 0
+
+
+class TestNoIssues:
+ def test_no_cx_no_via_passes(self):
+ """No CX and no via fields — clean pass."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ results = MarriedSegmentRule().check(itin, None)
+ assert len(results) == 1
+ assert results[0].passed is True
+ assert "No married segment risks" in results[0].message
diff --git a/tests/test_rules/test_via_counting.py b/tests/test_rules/test_via_counting.py
new file mode 100644
index 0000000..ded25ba
--- /dev/null
+++ b/tests/test_rules/test_via_counting.py
@@ -0,0 +1,182 @@
+"""Tests for via-stop continent counting in build_context.
+
+Via stops are through-flight technical stops (Rule 3015 §16).
+They count the stop's continent as visited for pricing, but do NOT
+add to per-continent segment limits (through-flight = one segment).
+"""
+
+import yaml
+from pathlib import Path
+
+from rtw.models import Itinerary, Ticket, Segment, Continent
+from rtw.validator import build_context
+
+
+def _make_itinerary(segments_data, origin="LHR", ticket_type="DONE4"):
+ ticket = Ticket(type=ticket_type, cabin="business", origin=origin)
+ segments = [Segment(**s) for s in segments_data]
+ return Itinerary(ticket=ticket, segments=segments)
+
+
+class TestViaContinentCounting:
+ """Tests for _detect_via_continents in build_context."""
+
+ def test_via_adds_new_continent(self):
+ """Via stop in a new continent adds it to continents_visited."""
+ # Route: SYD -> LAX (via SIN) -> GRU -> JFK -> SYD
+ # Without via: SWP, N_America, S_America = 3
+ # With via SIN: SWP, N_America, S_America, Asia = 4
+ segs = [
+ {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"},
+ {"from": "LAX", "to": "GRU", "carrier": "AA"},
+ {"from": "GRU", "to": "JFK", "carrier": "AA"},
+ {"from": "JFK", "to": "SYD", "carrier": "QF", "type": "final"},
+ ]
+ itin = _make_itinerary(segs, origin="SYD")
+ ctx = build_context(itin)
+ assert Continent.ASIA in ctx.continents_visited
+ assert Continent.ASIA in ctx.via_continents
+
+ def test_via_same_continent_no_effect(self):
+ """Via stop in an already-visited continent doesn't duplicate."""
+ # NRT->SYD via SIN: SIN is Asia, same as NRT departure
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ ctx = build_context(itin)
+ # Asia should appear exactly once in continents_visited
+ asia_count = ctx.continents_visited.count(Continent.ASIA)
+ assert asia_count == 1
+ # But via_continents should still record it
+ assert Continent.ASIA in ctx.via_continents
+
+ def test_via_does_not_increment_segments_per_continent(self):
+ """Via stop does NOT add to segments_per_continent."""
+ # SYD->LAX via SIN: Asia counted via stop, not as a segment
+ segs = [
+ {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"},
+ {"from": "LAX", "to": "GRU", "carrier": "AA"},
+ {"from": "GRU", "to": "JFK", "carrier": "AA"},
+ {"from": "JFK", "to": "SYD", "carrier": "QF", "type": "final"},
+ ]
+ itin = _make_itinerary(segs, origin="SYD")
+ ctx = build_context(itin)
+ # Asia should have 0 segments (only counted via via-stop)
+ assert ctx.segments_per_continent.get(Continent.ASIA, 0) == 0
+
+ def test_via_continent_segments_tracking(self):
+ """via_continent_segments records which segment and airport triggered detection."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ ctx = build_context(itin)
+ assert Continent.ASIA in ctx.via_continent_segments
+ entries = ctx.via_continent_segments[Continent.ASIA]
+ assert len(entries) == 1
+ seg_idx, via_apt = entries[0]
+ assert seg_idx == 1 # Second segment (NRT->SYD)
+ assert via_apt == "SIN"
+
+ def test_multiple_via_stops(self):
+ """Multiple via stops on a single segment all counted."""
+ # Hypothetical: DOH->ADL via SIN,KUL
+ segs = [
+ {"from": "LHR", "to": "DOH", "carrier": "QR"},
+ {"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["SIN", "KUL"]},
+ {"from": "ADL", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ ctx = build_context(itin)
+ assert Continent.ASIA in ctx.via_continents
+ entries = ctx.via_continent_segments[Continent.ASIA]
+ # Both SIN and KUL are Asia, so two entries
+ assert len(entries) == 2
+ airports = [e[1] for e in entries]
+ assert "SIN" in airports
+ assert "KUL" in airports
+
+ def test_via_works_alongside_implicit_asia(self):
+ """Via counting and implicit Asia detection work independently."""
+ # DOH->SYD triggers implicit Asia, NRT->SYD has via: SIN
+ segs = [
+ {"from": "LHR", "to": "DOH", "carrier": "QR"},
+ {"from": "DOH", "to": "SYD", "carrier": "QR"}, # Implicit Asia
+ {"from": "SYD", "to": "NRT", "carrier": "QF"},
+ {"from": "NRT", "to": "LAX", "carrier": "JL", "via": "SIN"}, # Via Asia
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ ctx = build_context(itin)
+ # Both mechanisms should detect Asia
+ assert Continent.ASIA in ctx.implicit_continents
+ assert Continent.ASIA in ctx.via_continents
+ # But only one entry in continents_visited
+ assert ctx.continents_visited.count(Continent.ASIA) == 1
+
+ def test_no_via_field_no_via_continents(self):
+ """Segments without via field produce empty via_continents."""
+ segs = [
+ {"from": "LHR", "to": "NRT", "carrier": "JL"},
+ {"from": "NRT", "to": "SYD", "carrier": "QF"},
+ {"from": "SYD", "to": "LAX", "carrier": "QF"},
+ {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"},
+ ]
+ itin = _make_itinerary(segs)
+ ctx = build_context(itin)
+ assert ctx.via_continents == []
+ assert ctx.via_continent_segments == {}
+
+
+class TestViaFixtureIntegration:
+ """Integration test using the via_through_flight.yaml fixture."""
+
+ def test_fixture_loads_and_validates(self):
+ """via_through_flight.yaml loads correctly and passes validation."""
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml"
+ with open(fixture_path) as f:
+ data = yaml.safe_load(f)
+ itin = Itinerary(**data)
+ ctx = build_context(itin)
+
+ # 5 continents: EU_ME, Asia, SWP, N_America, S_America
+ assert len(ctx.continents_visited) == 5
+ assert Continent.EU_ME in ctx.continents_visited
+ assert Continent.ASIA in ctx.continents_visited
+ assert Continent.SWP in ctx.continents_visited
+ assert Continent.N_AMERICA in ctx.continents_visited
+ assert Continent.S_AMERICA in ctx.continents_visited
+
+ def test_fixture_via_detected(self):
+ """Fixture's via: SIN is detected in via_continent_segments."""
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml"
+ with open(fixture_path) as f:
+ data = yaml.safe_load(f)
+ itin = Itinerary(**data)
+ ctx = build_context(itin)
+
+ assert Continent.ASIA in ctx.via_continents
+ entries = ctx.via_continent_segments[Continent.ASIA]
+ assert any(apt == "SIN" for _, apt in entries)
+
+ def test_fixture_via_no_segment_count(self):
+ """Via stop doesn't inflate segments_per_continent."""
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml"
+ with open(fixture_path) as f:
+ data = yaml.safe_load(f)
+ itin = Itinerary(**data)
+ ctx = build_context(itin)
+
+ # Asia segments: LHR->NRT counts (intra-continental would be 0 since
+ # it's intercontinental TC2->TC3). Via SIN should NOT add any.
+ # The key: via_continent_segments records via, but segments_per_continent doesn't.
+ via_asia_count = len(ctx.via_continent_segments.get(Continent.ASIA, []))
+ assert via_asia_count >= 1 # At least the via: SIN detection