# Contract: flights_api_client

**Component**: c12_operator_orchestrator
**Producer task**: AZ-489 — `_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md`
**Consumer tasks**: AZ-326 (CLI app — wires `--flight-id` / `--flight-file` flags), AZ-328 (build-cache orchestrator — calls `fetch_flight` / `load_flight_file`, then `bbox_from_waypoints` + `takeoff_origin_from_flight`)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-11

## Purpose

Defines the operator-workstation ↔ parent-suite `flights` REST service boundary plus an offline fallback for the same DTO shape. C12 consumes a typed `FlightDto` (waypoints + altitudes) to derive the cache bbox and the takeoff origin per ADR-010. The companion never sees this contract; it lives entirely on the operator workstation.

The boundary is split into two sources that produce the **same DTO shape**:

- **Online**: `GET /flights/{id}` + `GET /flights/{id}/waypoints` against the parent-suite `flights` REST service.
- **Offline**: a local JSON export in the same DTO shape (for operators without a workstation-to-flights path, e.g., field-deployed laptops).

## Shape

### DTOs

```python
from dataclasses import dataclass
from enum import Enum
from typing import Protocol, runtime_checkable
from uuid import UUID
from pathlib import Path

from gps_denied_onboard._types.geo import LatLonAlt, BoundingBox


class WaypointObjective(Enum):
    TAKEOFF = "takeoff"
    WAYPOINT = "waypoint"
    LOITER = "loiter"
    LANDING = "landing"
    OTHER = "other"


class WaypointSource(Enum):
    OPERATOR = "operator"
    IMPORT = "import"
    OTHER = "other"


@dataclass(frozen=True, slots=True)
class WaypointDto:
    ordinal: int                       # >= 0; defines the order of the waypoint inside the Flight
    lat_deg: float                     # -90 <= lat <= 90
    lon_deg: float                     # -180 <= lon <= 180
    alt_m: float                       # WGS84 ellipsoidal height; finite
    objective: WaypointObjective
    source: WaypointSource


@dataclass(frozen=True, slots=True)
class FlightDto:
    flight_id: UUID
    name: str
    waypoints: tuple[WaypointDto, ...] # ORDERED by ordinal ascending; non-empty
```

### Protocol

```python
@runtime_checkable
class FlightsApiClient(Protocol):
    """Read a Flight from the parent-suite flights REST service or a local JSON export.

    Pure read; no side effects beyond logging. Caller (C12 CacheBuildWorkflow)
    decides which source to use based on CLI flags (--flight-id vs --flight-file).
    """

    def fetch_flight(
        self,
        *,
        flight_id: UUID,
        base_url: str,
        auth_token: str,
        timeout_s: float = 10.0,
    ) -> FlightDto: ...

    def load_flight_file(self, *, path: Path) -> FlightDto: ...

    def bbox_from_waypoints(
        self,
        waypoints: tuple[WaypointDto, ...],
        *,
        buffer_m: float = 1000.0,
    ) -> BoundingBox: ...

    def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt: ...
```

### Exceptions

| Exception | When raised | Caller action |
|-----------|-------------|---------------|
| `FlightsApiUnreachableError` | HTTPS timeout / connection refused / 5xx | Operator retries online when network recovers OR switches to `--flight-file` offline path |
| `FlightsApiAuthError` | HTTP 401 / 403 | Operator refreshes suite credentials; never silently fall back to offline |
| `FlightNotFoundError` | HTTP 404 for the given `flight_id` | Operator verifies the GUID in the Mission Planner UI |
| `FlightsApiSchemaError` | Response body fails `FlightDto` validation (online) OR JSON file fails the same validation (offline) | Operator re-exports from the Mission Planner UI; bug in the schema map is a release-blocking defect |
| `FlightFileNotFoundError` | `path` does not exist (offline) | Operator confirms the path |
| `EmptyWaypointsError` | Resolved `FlightDto` carries zero waypoints — `bbox_from_waypoints` / `takeoff_origin_from_flight` cannot proceed | Operator re-plans in Mission Planner UI |
| `WaypointSchemaError` | Individual `WaypointDto` is malformed (lat out of range, NaN alt, negative ordinal, gap in ordering) | Operator re-exports / re-plans |

The Protocol does NOT catch these inside the methods — it raises and lets `CacheBuildWorkflow` translate to `CacheBuildError` with the appropriate `failure_phase=flight_resolve`.

## Invariants

| ID | Invariant | Why |
|----|-----------|-----|
| FAC-INV-1 | The online (`fetch_flight`) and offline (`load_flight_file`) paths return DTOs with **the same shape and the same validation contract**. The only difference is where the bytes come from. | Caller can swap paths without conditional handling. |
| FAC-INV-2 | `FlightDto.waypoints` is non-empty and is ordered by ascending `ordinal`. Implementations MUST sort + validate the ordering. | `waypoints[0]` is the takeoff origin per ADR-010; ordering is operationally meaningful. |
| FAC-INV-3 | `bbox_from_waypoints` envelopes the lat/lon of every waypoint and inflates by the `buffer_m` parameter (default 1 km). The buffer is a horizontal-distance expansion, not a degree-space expansion — implementations use `WgsConverter` to inflate correctly at the Flight's latitude. | A degree-space buffer would be 1.5× too narrow at high latitudes and miss tiles near the poles. |
| FAC-INV-4 | `takeoff_origin_from_flight` returns `LatLonAlt(waypoints[0].lat_deg, waypoints[0].lon_deg, waypoints[0].alt_m)` — no rounding, no projection. | The operator authored this point; we pass it through. |
| FAC-INV-5 | `fetch_flight` issues at most ONE retry on transient 5xx (with 1 s backoff) and at most ONE retry on connection error. 401/403/404/schema failures are NOT retried. | Operator-visible failures should be loud; transient blips should not require operator intervention. |
| FAC-INV-6 | `fetch_flight` is the ONLY method that makes network calls. `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight` are pure / filesystem-only. | Composability — the offline path is fully usable on an air-gapped operator workstation. |
| FAC-INV-7 | `auth_token` is never logged; structured logs redact the field. | Operator credential hygiene. |
| FAC-INV-8 | No write methods. This client is strictly read-only against the flights service. | Single responsibility — C12 does not author flights. |

## Non-Goals

- Flight authoring / editing — the Mission Planner UI (`suite/ui`) owns that.
- Live updates / websockets — pre-flight only.
- Caching the `FlightDto` across runs — every `build-cache` invocation re-fetches OR re-reads the file.
- Posting build status back to the `flights` REST service — out of scope this cycle.

## Versioning

- v1.0.0 — initial Protocol surface (this document).
- Breaking changes (changing DTO shape, removing a method) — bump major.
- Additive changes (new optional kwarg, new enum value) — bump minor. Consumers MUST handle unknown enum values gracefully.
- Patch — clarifications, doc edits.

| Version | Date | Notes | Author |
|---------|------|-------|--------|
| 1.0.0 | 2026-05-11 | Initial contract — produced by AZ-489 (ADR-010 cold-start origin via operator-planned mission) | autodev |

## Test Cases (consumer side)

| ID | Scenario | Expected Outcome |
|----|----------|------------------|
| FAC-TC-1 | Online happy path: valid `flight_id`, reachable service, 3 waypoints | Returns `FlightDto` with 3 ordered waypoints |
| FAC-TC-2 | Online 404 (unknown flight_id) | Raises `FlightNotFoundError`; does NOT retry |
| FAC-TC-3 | Online 401 (bad auth_token) | Raises `FlightsApiAuthError`; does NOT retry; does NOT log token |
| FAC-TC-4 | Online 503 transient | Retries once with 1 s backoff; succeeds; returns DTO |
| FAC-TC-5 | Online 503 persistent | Raises `FlightsApiUnreachableError` after one retry |
| FAC-TC-6 | Online connection refused | Retries once; raises `FlightsApiUnreachableError` after one retry |
| FAC-TC-7 | Online schema drift (response missing `lat`) | Raises `FlightsApiSchemaError` with field reference |
| FAC-TC-8 | Offline happy path: well-formed JSON | Returns equivalent `FlightDto` |
| FAC-TC-9 | Offline file missing | Raises `FlightFileNotFoundError` |
| FAC-TC-10 | Offline JSON missing waypoints array | Raises `FlightsApiSchemaError` |
| FAC-TC-11 | Empty waypoints | `fetch_flight` / `load_flight_file` accept but downstream `bbox_from_waypoints` raises `EmptyWaypointsError` |
| FAC-TC-12 | Waypoint with `lat=200` | Raises `WaypointSchemaError` during DTO validation |
| FAC-TC-13 | Waypoints out of ordinal order | Implementation sorts + validates; returns `FlightDto` with sorted tuple |
| FAC-TC-14 | Waypoints with ordinal gap (0, 1, 3) | Raises `WaypointSchemaError` (gap implies missing waypoint, not robust to silent reorder) |
| FAC-TC-15 | `bbox_from_waypoints(buffer_m=1000)` at mid-latitudes (~50°N) | Returned bbox extends ~1 km horizontally on all sides, NOT 1° in degree space |
| FAC-TC-16 | `takeoff_origin_from_flight(flight)` with `waypoints[0] = (50.0, 36.2, 200.0)` | Returns `LatLonAlt(50.0, 36.2, 200.0)` exactly |
| FAC-TC-17 | Conformance: `isinstance(impl, FlightsApiClient)` | `True` |
| FAC-TC-18 | Online + Offline produce byte-identical `FlightDto` for the same source | `assert online_dto == offline_dto` |
