# Batch 21 — AZ-489 C12 FlightsApiClient

**Date**: 2026-05-12
**Tracker**: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done.
**Cycle**: 1
**Status**: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips).

## Scope landed

AZ-489 delivers the operator-workstation → parent-suite `flights` REST service
boundary plus the offline JSON fallback, exactly as frozen by
`_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`
v1.0.0. ADR-010's primary cold-start path now has a real source for both the
cache bbox and the takeoff origin.

The implementation is strictly scoped to AZ-489 — the CLI flag plumbing
(AZ-326), the orchestrator phase 0 (AZ-328), and the
`OperatorToolServices` aggregate dataclass all stay out of scope.

### Public surface

* `src/gps_denied_onboard/components/c12_operator_tooling/flights_api/`
  package with:
  * `interface.py` — `FlightsApiClient` Protocol (`@runtime_checkable`) +
    `FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`.
    DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the
    project's `LatLonAlt` / `PoseEstimate` pattern.
  * `errors.py` — exception hierarchy: `FlightsApiError` (base) →
    `FlightsApiUnreachableError`, `FlightsApiAuthError`,
    `FlightNotFoundError`, `FlightsApiSchemaError`,
    `FlightFileNotFoundError`, `EmptyWaypointsError`,
    `WaypointSchemaError`.
  * `_parser.py` — shared JSON-payload → `FlightDto` validator used by
    both online and offline paths so they satisfy FAC-INV-1 (same shape,
    same error types).
  * `bbox.py` — `bbox_from_waypoints(waypoints, *, buffer_m=1000.0)`
    envelopes lat/lon and inflates by a horizontal-distance buffer via
    `WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt`
    (FAC-INV-3); `takeoff_origin_from_flight(flight)` passes
    `waypoints[0]` through without rounding (FAC-INV-4).
  * `file_loader.py` — `load_flight_file(*, path)` reads JSON via
    `orjson`, delegates to the parser; raises
    `FlightFileNotFoundError` / `FlightsApiSchemaError` /
    `WaypointSchemaError` as documented.
  * `httpx_client.py` — `HttpxFlightsApiClient` against the parent-suite
    REST API. Single retry on transient 5xx + connection errors per
    FAC-INV-5. Token redaction enforced at every log site. `transport`
    + `sleep` are constructor-injectable for unit tests.
* `src/gps_denied_onboard/runtime_root/c12_factory.py` —
  `build_flights_api_client(config) -> FlightsApiClient`. Tiny factory:
  one concrete strategy; httpx's defaults already enforce TLS verify ON
  and the system trust store.
* `src/gps_denied_onboard/runtime_root/__init__.py` —
  `build_flights_api_client` re-exported alongside the other
  `build_*` factories.
* `src/gps_denied_onboard/components/c12_operator_tooling/__init__.py`
  — flights_api surface re-exported as the C12 public API.
* `pyproject.toml` — `httpx>=0.28,<1.0` added to main deps.

### HTTP client choice

Selected `httpx` over `requests` (user-confirmed). Rationale:

* `httpx.MockTransport` is part of httpx — no `responses` /
  `requests-mock` test dep required.
* No prior C12 / C11 HTTP code yet uses `requests`; the only listing
  is a pyproject.toml dep entry without a consumer. Picking `httpx`
  costs one new dep but avoids two (httpx vs requests + responses).
* TLS verify is on by default; the constructor does not accept a
  `verify=False` toggle from config.

The AZ-489 spec doc + the C12 description + the flights_api_client
contract already reference `httpx`; no design drift was introduced
by this choice.

## Test coverage

`tests/unit/c12_operator_tooling/test_az489_flights_api_client.py` — 28
tests across AC-1..AC-18 plus extra coverage.

| AC | Test focus | Outcome |
|----|-----------|---------|
| AC-1 | Online 200 + 3-waypoint payload → `FlightDto`; ONE INFO log; ZERO token leak | green |
| AC-2 | Online 404 → `FlightNotFoundError`; transport hit once; no retry | green |
| AC-3 | Online 401 → `FlightsApiAuthError`; no retry; token absent from logs and exception text | green |
| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN `c12.flights.fetch.retry` | green |
| AC-5 | Online 503 always → `FlightsApiUnreachableError` after ONE retry | green |
| AC-6 | Missing `lat_deg` in waypoint payload → `WaypointSchemaError` naming the field | green |
| AC-7 | Offline well-formed JSON → equivalent `FlightDto` | green |
| AC-8 | Offline missing file → `FlightFileNotFoundError` with path in message | green |
| AC-9 | `bbox_from_waypoints(())` → `EmptyWaypointsError` | green |
| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green |
| AC-11 | `takeoff_origin_from_flight` returns `waypoints[0]` byte-equal (no rounding) | green |
| AC-12 | `isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True`; same for factory output | green |
| AC-13 | Online + offline DTOs from the same payload compare equal | green |
| AC-14 | Shuffled ordinals `[2, 0, 1]` returned sorted | green |
| AC-15 | Ordinal gap `[0, 1, 3]` → `WaypointSchemaError` | green |
| AC-16 | `lat_deg=200` → `WaypointSchemaError` naming the field + value | green |
| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green |
| AC-18 | Persistent `httpx.ConnectError` → `FlightsApiUnreachableError` after one 1 s retry | green |
| Bonus | Malformed JSON file → `FlightsApiSchemaError` | green |
| Bonus | Negative `buffer_m` → `ValueError` | green |
| Bonus | Zero `buffer_m` → envelope-only bbox; matches expected within 1% | green |
| Bonus | Missing top-level `flight_id` in JSON file → `FlightsApiSchemaError` | green |
| Bonus | Negative ordinal → `WaypointSchemaError` | green |
| Bonus | `takeoff_origin_from_flight(empty_flight)` → `EmptyWaypointsError` | green |

Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20.

## Quality gates

* `ruff check` on every changed file — clean (fixed: `UP037` quoted
  annotation in c12_factory; `RUF100` unused `noqa` in test file).
* `ReadLints` on the changed surface — no diagnostics.
* Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips
  for `cmake` and `actionlint` in CI-only scaffold tests).

## Architectural notes

* **FAC-INV-1 enforcement**: online and offline paths share the SAME
  `parse_flight_payload` validator; they differ only in how the bytes
  are sourced (httpx response vs file read). The AC-13 equality test
  is the visible proof.
* **FAC-INV-3 horizontal-distance buffer**: implemented as a local-ENU
  round-trip centred at the bbox envelope's midpoint. Picking the
  midpoint as the ENU origin (NOT one of the corners) keeps the
  inflation symmetric at high latitudes; the AC-10 test exercises 50° N
  and validates against the metres-per-degree expectations.
* **FAC-INV-5 retry semantics**: the implementation issues at most ONE
  retry — on transient 5xx OR connection error. 401/403/404 + schema
  errors are never retried. The retry path emits a single WARN log
  `c12.flights.fetch.retry`; the test fixture injects a fake `sleep`
  to avoid the real 1 s backoff while still asserting the timing.
* **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs
  the auth_token in any code path. Every structured log emits the
  literal `"<redacted>"`. The AC-17 parametrised test exercises happy,
  401, 404, and 500 paths and asserts the token literal is absent from
  the captured log buffer.

## Cross-task notes

* **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489
  spec mentions extending an `OperatorToolServices` dataclass at
  `runtime_root/c12_factory.py`, but that dataclass doesn't exist yet
  — it's part of AZ-328's territory (the build-cache orchestrator).
  Per scope-discipline this batch ships ONLY the `build_flights_api_client`
  factory function; AZ-328 will create the aggregate and wire the
  flights client through it.
* **AZ-326 (CLI flag plumbing)** can now consume the new
  `FlightsApiClient` and `--flight-file` path; the CLI must validate
  the `--flight-id` vs `--flight-file` exclusivity and translate
  `FlightsApiError` subclasses to the documented exit codes.
* **AZ-328 (build-cache orchestrator)** can now wire the flight-resolve
  phase 0 against the new client + DTOs.
* **AZ-419 (FT-P-11 cold-start scenario)** has both forward deps
  resolved on this side (AZ-489 done) and waits on AZ-490 for the C5
  `set_takeoff_origin` entrypoint.

## Tracker

* Jira AZ-489 transitioned To Do → In Progress → Done.
* Comment added on AZ-489 summarising deliverables + deviations from
  the original Jira description (Jira description predates the AZ-489
  spec doc; the doc is the authoritative source).
* AZ-489 spec file moved from `_docs/02_tasks/todo/` to
  `_docs/02_tasks/done/`.
