openapi: 3.0.3
info:
  title: Norba API — NDC Aggregation
  version: "2.0.0"
  description: |
    Norba is an NDC aggregation gateway for travel sellers. A single REST/JSON
    contract fans out shopping and order-management calls to airline NDC
    connectors — currently **American Airlines** — and normalizes every
    response into one airline-agnostic shape.

    The API has two surfaces:

    * **NDC commerce** (`/v1/shopping`, `/v1/offers`, `/v1/orders`,
      `/v1/monitoring`) — flight search, offer pricing, seats & services,
      order lifecycle (create / retrieve / change / cancel / refund) and
      connector health. Simple JSON bodies; errors use `{"error": "message"}`.
    * **Aviation Data** (`/v1/aviation`, `/v1/reference`) — reference data on
      airports, airlines, aircraft types, registrations and manufacturers,
      aggregated from open and official sources. Every response is wrapped in
      a canonical envelope `{data, meta, errors[]}`; errors are surfaced as
      `errors[{code, message}]` inside the envelope.

    **Request bodies are capped at 1 MiB.** A body over the cap is refused with
    `413` and `{"error": "request body too large"}` — not the `400` it used to
    be, which was indistinguishable from malformed JSON. Malformed JSON is still
    `400`, now with a generic message: the decoder's own text quoted the
    offending body back at the caller and into the logs.

    **Booking availability** — order *writes* (POST/PUT/DELETE under
    `/v1/orders`) can be switched off for an environment. While they are, they
    return `503` with body `{"error": "coming_soon", ...}` and a `Retry-After`;
    read endpoints stay functional.

    A developer portal (self-service API keys, usage dashboard, billing and an
    agency dashboard) exists alongside this API; those surfaces are not part
    of this public contract and are not documented here.
servers:
  - url: https://api.norba.io
    description: Production
  - url: http://localhost:8080
    description: Local development
security:
  - ApiKeyAuth: []
tags:
  - name: Health
  - name: Shopping
    description: AirShopping search and offer pricing
  - name: Offers
    description: Per-offer seats and ancillary services
  - name: Orders
    description: Order lifecycle — create, retrieve, change, cancel, refund
  - name: Monitoring
    description: Airline connector health
  - name: Aviation Data
    description: Airports, airlines, aircraft reference data (envelope responses)
  - name: Reference
    description: Upstream data-source catalog

paths:
  # ── Public ────────────────────────────────────────────────────────────────
  /v1/health:
    get:
      tags: [Health]
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /v1/health/ready:
    get:
      tags: [Health]
      summary: Readiness probe
      description: |
        Whether this process can actually serve requests — what a deploy gate
        should look at, unlike `/v1/health`, which deliberately checks nothing.

        Three things are inspected: the database answers, a keyring was built,
        and every key the registry marks ACTIVE was actually loaded by this
        process. The third is the failure that is otherwise invisible: the API
        boots fine without its encryption keys and then refuses, at request
        time, to store anything that needs them.

        What it reports is deliberately coarse — statuses and counts, never
        which purposes are missing and never a key id. A missing dependency is
        reported as "not configured" rather than as a failure, because a
        deployment without that dependency is a valid deployment.
      security: []
      responses:
        "200":
          description: Ready to serve
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Readiness" }
        "503":
          description: Not ready — this replica should be taken out of rotation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Readiness" }

  /v1/subscribe:
    post:
      tags: [Health]
      summary: Early-access signup
      description: Accepts an email address and notifies the Norba team. Nothing is persisted.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Signup recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: subscribed }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422":
          description: Invalid email
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ── Shopping ──────────────────────────────────────────────────────────────
  /v1/shopping/offers:
    post:
      tags: [Shopping]
      summary: Search flights (AirShopping)
      description: |
        Fans the search out to every registered airline connector and returns
        normalized offers. Identical searches inside the cache TTL are served
        from cache; empty result sets are never cached, so a route that came up
        empty because a connector was down recovers on the next call. Rate
        limited to 60 requests/minute per agency (429 on excess).

        ROUND TRIPS, CONNECTIONS AND MULTI-CITY WORK. They return offers and they
        book.

        PARTNER-OPERATED CONNECTIONS ARE WITHHELD. A connection where a partner
        airline operates a leg is not confirmed by the carrier at booking, so
        those offers are withheld from results by default rather than allowed to
        reach checkout, and OrderCreate refuses one early too, before spending an
        airline call. Non-stop codeshares are unaffected, and the operating
        carrier is disclosed on every segment.

        A CARRIER MAY REFUSE AN OFFER IT JUST SOLD. Occasionally an airline's
        shopping engine sells a flight its ordering system will not confirm on
        that date ("[411] Flight Does Not Operate on Date Requested"), and
        nothing in the offer foresees it. The refusal comes back as a 502 with
        the carrier's own text; pick another offer. A "[9999] … PricingInfo
        references segment F1 which is missing" line that follows it is a
        cascade, not the root cause.

        BAD INPUT IS REFUSED WITH 400 BEFORE ANY AIRLINE IS CALLED: a same-city
        search, an airport code that is not three letters such as "BARCELONA", a
        departure date in the past, and more infants than adults all answer 400
        with a message naming the field. A 502 "all airline connections failed —
        please retry later" is therefore a genuine carrier or connectivity
        failure. A route no connected airline flies is neither: it answers 200
        with offers: null — validate airport codes against /v1/aviation/airports
        before shopping.

        A route the carrier simply does not fly is the one case short-circuited
        before the airline call, and it answers 200 with `offers: null` — NULL,
        not an empty array. Guard for it.
      operationId: searchOffers
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: All airline connections failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/shopping/offers/stream:
    post:
      tags: [Shopping]
      summary: Search flights, streamed (Server-Sent Events)
      description: |
        The progressive variant of `POST /v1/shopping/offers`. Identical request
        body, validation, caching, look-to-book accounting and rate limit — the
        only difference is delivery: each connector's batch is pushed the moment
        it lands, while slower airlines are still in flight.

        SSE rather than WebSockets because the stream is strictly one-way,
        short-lived (bounded by the aggregator timeout) and rides on a plain HTTP
        response, so it passes through proxies and CDN edges without an upgrade
        handshake and inherits the same auth middleware chain.

        Response `Content-Type` is `text/event-stream`, NOT `application/json`.

        Event protocol, in order:

          event: search   data: {"search_id":"srch_…","expires_at":"…"}   always first
          event: offers   data: {"offers":[…]}                            0..n, one per connector batch
          event: done     data: {"total":115}                             terminal on success
          event: error    data: {"error":"…"}                             terminal on failure

        Batches are NOT deduplicated across connectors — consumers key on
        `offer_id` as they accumulate. A cache hit emits the whole result set as
        a single `offers` event so the client code path is identical either way.
      operationId: searchOffersStream
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
      responses:
        "200":
          description: |
            The event stream. A terminal `error` event can occur after a 200 has
            already been sent, because the status line is written before the
            fan-out begins — check for it rather than relying on the status code.
          content:
            text/event-stream:
              schema:
                type: string
              example: |
                event: search
                data: {"search_id":"srch_01K2M8ZC3AC5N8XWQ7R4YB","expires_at":"2026-08-14T10:14:52Z"}

                event: offers
                data: {"offers":[{"offer_id":"AA-X2E80EC16-…|X2E80EC16-…-1-1","airline":"AA","total_amount":380.62}]}

                event: done
                data: {"total":115}
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/offers/price:
    post:
      tags: [Shopping]
      summary: Confirm offer price (OfferPrice)
      description: |
        Re-prices one or more transient offers before booking. The airline is
        inferred from the offer-ID prefix when `airline` is not supplied.
      operationId: priceOffer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferPriceRequest" }
      responses:
        "200":
          description: Confirmed price and terms
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PricedOffer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/UpstreamError" }

  /v1/offers/{offerID}/seats:
    get:
      tags: [Offers]
      summary: Seat map for an offer (SeatAvailability)
      description: |
        Returns the seat map for one segment of the offer when segment_id is
        given; the seats it returns carry that segment_id, which is repeated
        when buying. A carrier that publishes no map for a segment (partner
        metal, for instance) answers 404 seat_map_segment_not_found.

        {offerID} must be PERCENT-ENCODED: AA tokens carry "|" and ".". An order
        ID is also accepted here.
      operationId: getOfferSeats
      parameters:
        - $ref: "#/components/parameters/OfferID"
        - $ref: "#/components/parameters/AirlineQueryOptional"
      responses:
        "200":
          description: Seat map
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SeatMap" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }

  /v1/offers/{offerID}/services:
    get:
      tags: [Offers]
      summary: Ancillary services for an offer (ServiceList)
      operationId: getOfferServices
      parameters:
        - $ref: "#/components/parameters/OfferID"
        - $ref: "#/components/parameters/AirlineQueryOptional"
      responses:
        "200":
          description: Available services
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Service" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }

  # ── Orders ────────────────────────────────────────────────────────────────
  /v1/orders:
    get:
      tags: [Orders]
      summary: List orders for the authenticated agency
      operationId: listOrders
      parameters:
        - name: airline
          in: query
          schema: { type: string }
          description: Filter by IATA airline code
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OrderStatus" }
          description: Filter by order status
        - name: pnr
          in: query
          schema: { type: string }
          description: Filter by record locator
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: Page size (keyset pagination)
        - name: cursor
          in: query
          schema: { type: string }
          description: The next_cursor returned by the previous page
      responses:
        "200":
          description: A page of orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  next_cursor:
                    type: string
                    description: Cursor for the next page; empty on the last page
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Orders]
      summary: Create an order (OrderCreate)
      description: Booking write — returns 503 `coming_soon` unless booking is enabled.
      operationId: createOrder
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 128 }
          description: >-
            A repeated create with the same key returns the order the first
            call produced (with Idempotent-Replayed: true) rather than booking
            again. A key still in flight is 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateOrderRequest" }
      responses:
        "200":
          description: Idempotent replay of a previously created order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "201":
          description: Order created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Static (read-only) API key; booking requires an agency or dev-portal key
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >-
            order_conflict, idempotency_key_in_flight or idempotency_key_reused
        "422":
          description: >-
            extra_not_priced — a seat/ancillary could not be priced
            server-side; or the carrier cannot carry the supplied
            payment.authentication (American Airlines has no secure-payment
            node — omit the object)
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}:
    get:
      tags: [Orders]
      summary: Retrieve an order
      description: |
        Served from the agency's persisted order store when available;
        otherwise proxied to the airline (requires `?airline=XX`).
      operationId: getOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryOptional"
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/UpstreamError" }
    put:
      tags: [Orders]
      summary: Change an order (OrderChange)
      description: |
        Booking write — 503 `coming_soon` unless booking is enabled.
        Requires `?airline=XX`. The body maps directly onto the canonical
        ChangeRequest (field names match the Go struct; JSON key matching is
        case-insensitive).
      operationId: changeOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChangeRequest" }
      responses:
        "200":
          description: Updated order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }
    delete:
      tags: [Orders]
      summary: Cancel an order (OrderCancel)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`.
      operationId: cancelOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      responses:
        "204": { description: Order cancelled }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/quote:
    post:
      tags: [Orders]
      summary: Quote a change/cancel on an order (OrderQuote)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`. No request body.
      operationId: quoteOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      responses:
        "200":
          description: Quote
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderQuote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/commit:
    post:
      tags: [Orders]
      summary: Commit ancillaries/seats to an order (OrderCommit)
      description: |
        Booking write — 503 `coming_soon` unless booking is enabled.
        Requires `?airline=XX`. Finalises an upsell (bags, seats) with payment.
      operationId: commitOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CommitRequest" }
      responses:
        "200":
          description: Updated order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/reshop:
    post:
      tags: [Orders]
      summary: Search alternative flights for an order (OrderReShop)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`.
      operationId: reshopOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReShopRequest" }
      responses:
        "200":
          description: Alternative offers
          content:
            application/json:
              schema:
                type: object
                properties:
                  offers:
                    type: array
                    items: { $ref: "#/components/schemas/Offer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/change:
    post:
      tags: [Orders]
      summary: Quote an order change (change step 1 of 2)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`.
      operationId: quoteOrderChange
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                action:
                  type: string
                  description: '"reissue", "ancillaries" or "cancel_segments"'
                segment_ids:
                  type: array
                  items: { type: string }
                reason: { type: string }
      responses:
        "200":
          description: Change quote
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderQuote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/change/confirm:
    post:
      tags: [Orders]
      summary: Confirm an order change (change step 2 of 2)
      description: >
        Accepts a reshop offer and reissues the order against it.


        A reissue is two-step: call `POST /v1/orders/{orderID}/reshop` first to
        get alternative offers, then pass the `offer_id` of the option you are
        accepting here. Without `offer_id` this returns 400 — there is nothing to
        reissue against, since the airline is being asked to replace the order
        with a specific offer rather than to "change" it in the abstract.


        `change_quote_id` is accepted but advisory: the airline reprices at
        acceptance regardless of what the quote said earlier.


        Booking write — 503 `coming_soon` unless booking is enabled.
        Requires `?airline=XX`.
      operationId: confirmOrderChange
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [offer_id]
              properties:
                offer_id:
                  type: string
                  description: The `offer_id` of the reshop offer being accepted.
                offer_ids:
                  type: array
                  items: { type: string }
                  description: Additional reshop offers, when the change spans more than one.
                change_quote_id:
                  type: string
                  description: Advisory; from POST /v1/orders/{orderID}/change.
      responses:
        "200":
          description: Updated order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/cancel:
    post:
      tags: [Orders]
      summary: Quote an order cancellation (cancel step 1 of 2)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`.
      operationId: quoteOrderCancel
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                passenger_ids:
                  type: array
                  items: { type: string }
                segment_ids:
                  type: array
                  items: { type: string }
                reason: { type: string }
      responses:
        "200":
          description: Cancellation quote (refund vs. penalty)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CancellationQuote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/cancel/confirm:
    post:
      tags: [Orders]
      summary: Confirm an order cancellation (cancel step 2 of 2)
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`.
      operationId: confirmOrderCancel
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                cancellation_quote_id: { type: string }
      responses:
        "200":
          description: Cancelled order (or minimal `{order_id, status}` if the airline retrieve fails)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  /v1/orders/{orderID}/refund-eligibility:
    get:
      tags: [Orders]
      summary: Check refund eligibility
      description: Read endpoint — available even while booking writes are gated. Requires `?airline=XX`.
      operationId: getRefundEligibility
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      responses:
        "200":
          description: Eligibility, refundable amount and penalty
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RefundEligibility" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }

  /v1/orders/{orderID}/refund:
    post:
      tags: [Orders]
      summary: Execute a refund
      description: Booking write — 503 `coming_soon` unless booking is enabled. Requires `?airline=XX`. No request body.
      operationId: refundOrder
      parameters:
        - $ref: "#/components/parameters/OrderID"
        - $ref: "#/components/parameters/AirlineQueryRequired"
      responses:
        "202":
          description: Refund accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_id: { type: string }
                  status: { type: string, example: refunded }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/ComingSoon" }

  # ── Monitoring ────────────────────────────────────────────────────────────
  /v1/monitoring/airlines:
    get:
      description: |
        Per-connector health, refreshed on a schedule rather than probed per
        request. The check never reaches an airline, so polling it does not
        touch your look-to-book ratio the way polling flight search does; each
        call is still a metered data request against the account's one-off free
        allowance.

        Worth wiring up early: flight search answers 200 with an empty/null
        `offers` both when there is genuinely no availability and when the route
        filter dropped the carrier, so search alone cannot tell you a connector
        is unreachable.
      tags: [Monitoring]
      summary: Health of every airline connector
      operationId: listAirlineHealth
      responses:
        "200":
          description: One entry per registered connector (empty list when monitoring is disabled)
          content:
            application/json:
              schema:
                type: object
                properties:
                  airlines:
                    type: array
                    items: { $ref: "#/components/schemas/AirlineHealth" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/monitoring/airlines/{iataCode}:
    get:
      tags: [Monitoring]
      summary: Health of one airline connector
      operationId: getAirlineHealth
      parameters:
        - name: iataCode
          in: path
          required: true
          schema: { type: string }
          example: AA
      responses:
        "200":
          description: Connector health
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AirlineHealth" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Aviation Data ─────────────────────────────────────────────────────────
  /v1/aviation/airports:
    get:
      tags: [Aviation Data]
      summary: Search airports
      operationId: listAirports
      parameters:
        - { name: iata, in: query, schema: { type: string }, description: IATA code filter }
        - { name: icao, in: query, schema: { type: string }, description: ICAO code filter }
        - { name: country, in: query, schema: { type: string }, description: ISO country code }
        - { name: city, in: query, schema: { type: string } }
        - { name: region, in: query, schema: { type: string }, description: ISO 3166-2 subdivision }
        - name: type
          in: query
          schema:
            type: string
            enum: [large_airport, medium_airport, small_airport, heliport, seaplane_base, closed]
        - { name: name, in: query, schema: { type: string }, description: Name search }
        - { name: sort, in: query, schema: { type: string } }
        - { name: lat, in: query, schema: { type: number }, description: Latitude for radius search }
        - { name: lon, in: query, schema: { type: number }, description: Longitude for radius search }
        - { name: radius_km, in: query, schema: { type: number }, description: Radius in km around lat/lon }
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated airports
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Airport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}:
    get:
      tags: [Aviation Data]
      summary: Get an airport by id, IATA or ICAO code
      operationId: getAirport
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: Airport
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Airport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}/full:
    get:
      tags: [Aviation Data]
      summary: Unified airport profile (airport + runways + frequencies + navaids)
      operationId: getAirportFull
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: Full airport profile
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          airport: { $ref: "#/components/schemas/Airport" }
                          runways:
                            type: array
                            items: { $ref: "#/components/schemas/Runway" }
                          frequencies:
                            type: array
                            items: { $ref: "#/components/schemas/Frequency" }
                          navaids:
                            type: array
                            items: { $ref: "#/components/schemas/Navaid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/iata/{iata}:
    get:
      tags: [Aviation Data]
      summary: Get an airport by IATA code
      operationId: getAirportByIATA
      parameters:
        - { name: iata, in: path, required: true, schema: { type: string }, example: MAD }
      responses:
        "200":
          description: Airport
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Airport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/icao/{icao}:
    get:
      tags: [Aviation Data]
      summary: Get an airport by ICAO code
      operationId: getAirportByICAO
      parameters:
        - { name: icao, in: path, required: true, schema: { type: string }, example: LEMD }
      responses:
        "200":
          description: Airport
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Airport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}/runways:
    get:
      tags: [Aviation Data]
      summary: Runways of an airport
      operationId: getAirportRunways
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: Runway list (meta carries `airport_iata` / `airport_icao`)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Runway" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}/frequencies:
    get:
      tags: [Aviation Data]
      summary: Radio frequencies of an airport (TWR/APP/GND/ATIS…)
      operationId: getAirportFrequencies
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: Frequency list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Frequency" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}/navaids:
    get:
      tags: [Aviation Data]
      summary: Navaids associated with an airport (VOR/DME/ILS/NDB)
      operationId: getAirportNavaids
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: Navaid list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Navaid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airports/{id}/timezone:
    get:
      tags: [Aviation Data]
      summary: Timezone of an airport
      operationId: getAirportTimezone
      parameters: [ $ref: "#/components/parameters/AirportID" ]
      responses:
        "200":
          description: IANA timezone
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          airport_id: { type: string }
                          timezone: { type: string, example: Europe/Madrid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airlines:
    get:
      tags: [Aviation Data]
      summary: Search airlines
      operationId: listAirlines
      parameters:
        - { name: iata, in: query, schema: { type: string } }
        - { name: icao, in: query, schema: { type: string } }
        - { name: country, in: query, schema: { type: string } }
        - { name: alliance, in: query, schema: { type: string } }
        - name: status
          in: query
          schema: { type: string, enum: [active, inactive, defunct] }
        - { name: name, in: query, schema: { type: string }, description: Name search }
        - { name: sort, in: query, schema: { type: string } }
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated airlines
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AviationAirline" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airlines/{id}:
    get:
      tags: [Aviation Data]
      summary: Get an airline by id, IATA or ICAO code
      operationId: getAirline
      parameters: [ $ref: "#/components/parameters/AirlineID" ]
      responses:
        "200":
          description: Airline
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/AviationAirline" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airlines/iata/{iata}:
    get:
      tags: [Aviation Data]
      summary: Get an airline by IATA code
      operationId: getAirlineByIATA
      parameters:
        - { name: iata, in: path, required: true, schema: { type: string }, example: BA }
      responses:
        "200":
          description: Airline
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/AviationAirline" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airlines/icao/{icao}:
    get:
      tags: [Aviation Data]
      summary: Get an airline by ICAO code
      operationId: getAirlineByICAO
      parameters:
        - { name: icao, in: path, required: true, schema: { type: string }, example: BAW }
      responses:
        "200":
          description: Airline
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/AviationAirline" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/airlines/{id}/status:
    get:
      tags: [Aviation Data]
      summary: Operational status of an airline
      operationId: getAirlineStatus
      parameters: [ $ref: "#/components/parameters/AirlineID" ]
      responses:
        "200":
          description: Coarse status (active / inactive / defunct / unknown)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          airline_id: { type: string }
                          status: { type: string, example: active }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/aircraft-types:
    get:
      tags: [Aviation Data]
      summary: Search aircraft types
      operationId: listAircraftTypes
      parameters:
        - { name: manufacturer, in: query, schema: { type: string } }
        - { name: class, in: query, schema: { type: string }, description: "e.g. narrowbody, widebody, regional, turboprop" }
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated aircraft types
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AircraftType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/aircraft-types/{icao_type}:
    get:
      tags: [Aviation Data]
      summary: Get an aircraft type by ICAO type designator
      operationId: getAircraftType
      parameters:
        - { name: icao_type, in: path, required: true, schema: { type: string }, example: A320 }
      responses:
        "200":
          description: Aircraft type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/AircraftType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AviationNotFound" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/manufacturers:
    get:
      tags: [Aviation Data]
      summary: List aircraft manufacturers
      operationId: listManufacturers
      responses:
        "200":
          description: Manufacturer names
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/aviation/manufacturers/{name}/aircraft-types:
    get:
      tags: [Aviation Data]
      summary: Aircraft types built by a manufacturer
      operationId: listManufacturerAircraftTypes
      parameters:
        - { name: name, in: path, required: true, schema: { type: string }, example: Airbus }
      responses:
        "200":
          description: Aircraft types
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AircraftType" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/AviationUnavailable" }

  /v1/reference/sources:
    get:
      tags: [Reference]
      summary: Catalog of upstream data sources
      description: Lists every upstream provider used by the Aviation Data API, with licensing and sync status.
      operationId: listSources
      responses:
        "200":
          description: Source catalog
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Source" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Security & account hardening (appended) ─────────────────────────────────
  # Session bearer token (gds_… issued by the auth flow), not the X-API-Key.
  /v1/dev/api-keys:
    get:
      tags: [Developer Portal]
      summary: List the caller's API keys (never their secrets)
      responses:
        "200":
          description: Every key the account owns, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/DeveloperAPIKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Developer Portal]
      summary: Create an API key, optionally restricted
      description: |
        The raw secret is returned exactly once, in `secret`. Every restriction
        is optional and enforced on every request the key makes; omitting them
        all yields an unrestricted key, which is what a body of `{"name": "…"}`
        has always produced.

        Refused on a support/impersonation session, for a non-agency account,
        and for an account whose email address is not verified.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/APIKeyWrite" }
      responses:
        "201":
          description: The new key, including its one-time secret
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DeveloperAPIKey"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: Shown exactly once; never returned again.
                        example: gor_4f2c9a1b8e7d6c5b4a39281706f5e4d3
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: Support session, non-agency account, or unverified email }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }

  /v1/dev/api-keys/{id}:
    patch:
      tags: [Developer Portal]
      summary: Rename an API key and/or edit its restrictions
      description: |
        A partial edit. Every field is optional; a field the body does not
        mention is left untouched, and an explicitly emptied one clears the
        restriction (`"allowedIps": []` removes an allowlist, `"expiresAt": ""`
        removes an expiry). A body that names nothing is `400`.

        The gate caches a key's restrictions for up to a minute; a successful
        edit evicts that entry, so a tightened allowlist applies immediately.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/APIKeyWrite" }
      responses:
        "200":
          description: The key as it now stands
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeveloperAPIKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: Refused on a support/impersonation session }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
    delete:
      tags: [Developer Portal]
      summary: Revoke an API key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: revoked } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/dev/api-keys/{id}/secret:
    post:
      tags: [Developer Portal]
      summary: Reveal an API key secret (step-up required)
      description: |
        Returns the plaintext secret of a stored key. Requires step-up: the
        account's current password in the body, OR a second factor verified
        within the freshness window. Refused (403) on a support/impersonation
        session and audited on every success. The former `GET` on this path now
        answers `405` with a hint.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                password: { type: string, description: current account password }
      responses:
        "200":
          description: The secret, shown to the owner
          content:
            application/json:
              schema: { type: object, properties: { secret: { type: string } } }
        "403": { description: Step-up not satisfied, or an impersonation session }
        "404": { description: Key not found }
    get:
      tags: [Developer Portal]
      summary: Removed — use POST
      responses:
        "405": { description: Method not allowed; POST with a password or fresh MFA }

  /v1/dev/me/security-events:
    get:
      tags: [Developer Portal]
      summary: The account's own security events
      parameters:
        - { name: kind, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer } }
      responses:
        "200":
          description: Paginated events for the caller
          content:
            application/json:
              schema:
                type: object
                properties:
                  events: { type: array, items: { type: object } }
                  total: { type: integer }

  /v1/admin/security/events:
    get:
      tags: [Admin]
      summary: Security event feed (platform.read)
      parameters:
        - { name: kind, in: query, schema: { type: string } }
        - { name: user_id, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer } }
      responses:
        "200": { description: Matching events, newest first }

  /v1/admin/security/events/{id}/ack:
    post:
      tags: [Admin]
      summary: Acknowledge an alert (platform.read)
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Acknowledged }
        "404": { description: Event not found }

  /v1/admin/security/lockouts:
    get:
      tags: [Admin]
      summary: Currently locked accounts (platform.read)
      responses:
        "200": { description: Locked accounts with lockout expiry }

  /v1/admin/security/lockouts/{userID}/unlock:
    post:
      tags: [Admin]
      summary: Lift a lockout (user.manage)
      parameters:
        - { name: userID, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Unlocked }
        "404": { description: User not found }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Agency or developer-portal API key.

  parameters:
    OfferID:
      name: offerID
      in: path
      required: true
      schema: { type: string }
      description: Canonical offer ID returned by AirShopping. The airline is inferred from its prefix.
    OrderID:
      name: orderID
      in: path
      required: true
      schema: { type: string }
    AirlineQueryOptional:
      name: airline
      in: query
      required: false
      schema: { type: string }
      description: IATA airline code. Inferred from the offer/order ID prefix when omitted.
    AirlineQueryRequired:
      name: airline
      in: query
      required: true
      schema: { type: string }
      description: IATA airline code that owns the order (routes the call to the right connector).
      example: AA
    AirportID:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Internal airport id, IATA code or ICAO code.
    AirlineID:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Internal airline id, IATA code or ICAO code.
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 50 }

  responses:
    BadRequest:
      description: Malformed or invalid request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid X-API-Key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UpstreamError:
      description: The airline system returned an error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ComingSoon:
      description: Booking writes are feature-gated in this environment (`Retry-After` header set)
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: coming_soon }
              message: { type: string }
              docs_url: { type: string, example: https://norba.io/docs/changelog }
    PayloadTooLarge:
      description: The request body exceeded the 1 MiB cap
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AviationBadRequest:
      description: Validation failed (envelope with errors[])
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
    AviationNotFound:
      description: Resource not found (envelope with errors[])
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
    AviationUnavailable:
      description: Aviation database not configured (envelope with errors[])
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }

  schemas:
    # ── Developer API keys ───────────────────────────────────────────────────
    APIKeyWrite:
      type: object
      description: |
        The create and edit body. On create, an absent restriction simply means
        "unrestricted"; on PATCH it means "leave this one alone".
      properties:
        name:
          type: string
          description: Human label. Defaults to "Default" on create; may not be blanked.
          example: Booking platform
        expiresAt:
          type: string
          description: |
            RFC 3339. Must be in the future and at most 2 years out. An empty
            string clears the expiry. An expired key is refused with `401`.
          example: "2027-01-31T23:59:59Z"
        allowedIps:
          type: array
          description: |
            CIDR blocks or bare addresses (max 20). A bare address is stored as
            its single-host prefix (`/32`, `/128`), and a CIDR with host bits set
            is refused. Empty = any address. A request from outside the list is
            refused with `403`.
          items: { type: string }
          example: ["203.0.113.0/24", "198.51.100.7/32"]
        scopes:
          type: array
          description: |
            Permission keys this credential may exercise (max 20). Empty = the
            account's full capability. A request needing a scope the key lacks is
            refused with `403`.
          items:
            type: string
            enum: [order.create, order.read, order.service, search.execute]
          example: ["search.execute", "order.read"]
        allowedPaths:
          type: array
          description: |
            Endpoint prefixes (max 20), each starting with `/`. Matched on whole
            path segments, so `/v1/orders` does not also allow
            `/v1/orders-export`. Empty = every endpoint; a request outside the
            list is refused with `403`.
          items: { type: string }
          example: ["/v1/shopping", "/v1/orders"]
    DeveloperAPIKey:
      type: object
      description: An API key as the dashboard sees it. The secret is never included.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Booking platform }
        prefix:
          type: string
          description: Display-safe head of the key.
          example: gor_4f2c9a1b8e
        active: { type: boolean }
        lastUsedAt: { type: string, format: date-time }
        revokedAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, description: Absent when the key does not expire. }
        allowedIps: { type: array, items: { type: string }, description: "Always present; [] means any address." }
        scopes: { type: array, items: { type: string }, description: "Always present; [] means the account's full capability." }
        allowedPaths: { type: array, items: { type: string }, description: "Always present; [] means every endpoint." }

    # ── Common / errors ──────────────────────────────────────────────────────
    Error:
      type: object
      description: Uniform error body for every non-2xx NDC/commerce response.
      properties:
        error: { type: string, example: "origin and destination are required" }

    Readiness:
      type: object
      properties:
        status: { type: string, enum: [ready, "not ready"] }
        checks:
          type: object
          properties:
            database:
              type: string
              enum: [up, down, "not configured"]
            encryptionKeys:
              type: string
              enum: [loaded, incomplete, unknown, "not configured"]
              description: |
                `incomplete` means the crypto_keys registry marks keys ACTIVE
                that this process never loaded. Deliberately a count internally,
                never a list of which purposes are missing — this endpoint is
                public, and that detail is more useful to an attacker than to an
                operator, who has the admin endpoint for it.

    Money:
      type: object
      properties:
        amount: { type: number, format: double, example: 123.45 }
        currency: { type: string, example: EUR, description: ISO 4217 code }

    CabinClass:
      type: string
      enum: [economy, premium_economy, business, first]

    OrderStatus:
      type: string
      enum: [pending, confirmed, ticketed, cancelled, partially_cancelled, disrupted, refunded, failed]

    # ── Itinerary ────────────────────────────────────────────────────────────
    Segment:
      type: object
      description: One takeoff and landing on a specific aircraft/flight/date.
      properties:
        segment_id: { type: string }
        origin: { type: string, example: MAD, description: IATA departure airport }
        destination: { type: string, example: LHR, description: IATA arrival airport }
        departure_utc: { type: string, format: date-time }
        arrival_utc: { type: string, format: date-time }
        flight_number: { type: string, example: AA456 }
        marketing_carrier: { type: string, example: AA }
        operating_carrier: { type: string }
        aircraft: { type: string, example: "320", description: Aircraft type IATA code }
        departure_terminal: { type: string }
        arrival_terminal: { type: string }
        duration_minutes: { type: integer }
        cabin_class: { $ref: "#/components/schemas/CabinClass" }
        booking_class: { type: string, example: Y }
        fare_basis: { type: string, example: YCA0NZ }
        meal_service: { type: string }

    Slice:
      type: object
      description: One directional leg of an itinerary (outbound or return); one or more segments.
      properties:
        id: { type: string }
        origin: { type: string, example: MAD }
        destination: { type: string, example: LHR }
        departure_utc: { type: string, format: date-time }
        arrival_utc: { type: string, format: date-time }
        duration_minutes: { type: integer, description: Total duration including layovers }
        stops: { type: integer, description: Number of connections (0 = non-stop) }
        segments:
          type: array
          items: { $ref: "#/components/schemas/Segment" }

    # ── Fares ────────────────────────────────────────────────────────────────
    FareBenefit:
      type: object
      properties:
        type:
          type: string
          enum: [carry_on, checked_bag, seat_selection, changes, refund, lounge, priority_boarding, miles_accrual, meal]
        included: { type: boolean }
        description: { type: string }
        value: { type: string, example: 23kg }

    FareFamily:
      type: object
      description: Branded fare bundle (e.g. "Economy Flex").
      properties:
        id: { type: string }
        name: { type: string, example: Flex }
        brand_id: { type: string }
        description: { type: string }
        benefits:
          type: array
          items: { $ref: "#/components/schemas/FareBenefit" }

    FareRules:
      type: object
      description: Normalized refund/change policy. Nullable booleans mean "unknown".
      properties:
        refundable: { type: boolean, nullable: true }
        refund_penalty_amount: { type: number, format: double }
        refund_penalty_currency: { type: string }
        refund_penalty_type: { type: string, enum: [fixed, percentage, none] }
        changeable: { type: boolean, nullable: true }
        change_penalty_amount: { type: number, format: double }
        change_penalty_currency: { type: string }
        change_penalty_type: { type: string, enum: [fixed, percentage, none] }
        raw_text: { type: string, description: Raw ATPCO fare-rules text }
        change_allowed_before_departure: { type: boolean, nullable: true }
        change_allowed_after_departure: { type: boolean, nullable: true }
        min_stay: { type: string }
        max_stay: { type: string }
        advance_purchase_required: { type: boolean, nullable: true }
        advance_purchase_days: { type: integer, nullable: true }

    # ── People ───────────────────────────────────────────────────────────────
    Document:
      type: object
      description: Travel document (passport / national ID).
      properties:
        type: { type: string, example: P }
        number: { type: string }
        issuing_country: { type: string, description: ISO 3166-1 alpha-2 }
        expiry_date: { type: string, example: "2030-05-01" }
        nationality: { type: string, description: ISO 3166-1 alpha-2 }

    Contact:
      type: object
      properties:
        email: { type: string, format: email }
        phone: { type: string, example: "+34612345678" }

    Passenger:
      type: object
      properties:
        id: { type: string, description: Unique within the order }
        type: { type: string, enum: [ADT, CHD, INF], description: IATA passenger type code }
        first_name: { type: string }
        last_name: { type: string }
        date_of_birth: { type: string, example: "1990-04-12" }
        gender: { type: string, example: M }
        document: { $ref: "#/components/schemas/Document" }
        contact: { $ref: "#/components/schemas/Contact" }

    PassengerPricing:
      type: object
      description: Per-passenger-type price breakdown (amounts are per passenger).
      properties:
        type: { type: string, enum: [ADT, CHD, INF] }
        count: { type: integer }
        base_fare: { type: number, format: double }
        taxes: { type: number, format: double }
        total: { type: number, format: double }

    # ── Ancillaries / seats ──────────────────────────────────────────────────
    Ancillary:
      type: object
      description: Canonical normalized ancillary (bag, seat, meal, lounge, ...).
      properties:
        id: { type: string }
        ancillary_offer_id: { type: string, description: Airline's original ancillary ID }
        type:
          type: string
          enum: [bag_checked, bag_carry_on, seat, meal, lounge, fast_track, priority_boarding, insurance, upgrade, pet, sport_equipment, extra_legroom, wifi, other]
        name: { type: string, example: Extra Baggage 23kg }
        description: { type: string }
        price_amount: { type: number, format: double, nullable: true, description: Null when included in fare }
        currency: { type: string }
        included_in_fare: { type: boolean }
        segment_ids:
          type: array
          items: { type: string }
        passenger_id: { type: string }
        passenger_type: { type: string }
        sub_code: { type: string, description: ATPCO ancillary sub-code }
        weight_kg: { type: integer, nullable: true }
        dimensions_cm: { type: string }
        segment_id: { type: string, description: Legacy single-segment reference }

    Service:
      type: object
      description: Raw ancillary offer from an NDC ServiceList response.
      properties:
        service_id: { type: string }
        type:
          type: string
          enum: [bag_checked, bag_carry_on, seat, meal, lounge, fast_track, priority_boarding, insurance, upgrade, pet, sport_equipment, extra_legroom, wifi, other]
        name: { type: string }
        description: { type: string }
        price: { $ref: "#/components/schemas/Money" }
        segment_refs:
          type: array
          items: { type: string }
        pax_refs:
          type: array
          items: { type: string }

    Seat:
      type: object
      properties:
        seat_id: { type: string }
        row: { type: integer }
        column: { type: string, example: A }
        cabin: { type: string }
        available: { type: boolean }
        price: { $ref: "#/components/schemas/Money" }
        features:
          type: array
          items: { type: string }
          example: [extra_legroom, exit_row]
        segment_id: { type: string }
        passenger_id: { type: string }

    SeatMap:
      type: object
      properties:
        flight_id: { type: string }
        airline: { type: string, example: AA }
        aircraft: { type: string }
        rows: { type: integer }
        seats:
          type: array
          items: { $ref: "#/components/schemas/Seat" }

    # ── Offers / search ──────────────────────────────────────────────────────
    Offer:
      type: object
      description: Bookable itinerary at a specific price. Transient — expires after ~15-30 minutes.
      properties:
        offer_id: { type: string }
        airline_offer_id: { type: string, description: Original airline NDC offer ID }
        airline: { type: string, example: AA }
        source: { type: string, enum: [ndc_direct, gds_fallback, lcc_direct] }
        source_version: { type: string, example: NDC-21.3 }
        currency: { type: string, example: EUR }
        total_amount: { type: number, format: double }
        base_fare: { type: number, format: double }
        taxes_fees: { type: number, format: double }
        total_price: { $ref: "#/components/schemas/Money" }
        expires_at: { type: string, format: date-time }
        slices:
          type: array
          items: { $ref: "#/components/schemas/Slice" }
        passengers:
          type: array
          items: { $ref: "#/components/schemas/PassengerPricing" }
        ancillaries_included:
          type: array
          items: { $ref: "#/components/schemas/Ancillary" }
        baggage_included: { type: boolean }
        fare_family: { $ref: "#/components/schemas/FareFamily" }
        fare_rules: { $ref: "#/components/schemas/FareRules" }
        cabin_class: { $ref: "#/components/schemas/CabinClass" }
        quality_score: { type: number, format: double, description: Data completeness score 0.0-1.0 }

    PricedOffer:
      allOf:
        - $ref: "#/components/schemas/Offer"
        - type: object
          properties:
            price_guaranteed_until:
              type: string
              format: date-time
              description: Deadline for completing the booking at this price

    SearchRequest:
      type: object
      required: [origin, destination, departure_date, adults]
      properties:
        origin: { type: string, example: LHR, description: IATA airport code }
        destination: { type: string, example: JFK, description: IATA airport code }
        departure_date: { type: string, example: "2026-09-01", description: ISO date YYYY-MM-DD }
        return_date: { type: string, example: "2026-09-10", description: Optional return leg date }
        adults: { type: integer, minimum: 1 }
        children: { type: integer, minimum: 0, description: Ages 2-11 }
        infants: { type: integer, minimum: 0, description: Under 2 }
        cabin_class: { $ref: "#/components/schemas/CabinClass" }
        currency: { type: string, example: GBP, description: ISO 4217 }
        max_stops:
          type: integer
          description: |
            ABSENT means no constraint; 0 means non-stop only; 1+ allows that
            many connections. Absent and 0 are genuinely different — a plain 0
            restricts the search to non-stop carriers and makes a route the
            airline serves with one connection look unserved.
        legs:
          type: array
          minItems: 2
          maxItems: 5
          description: |
            Multi-city itinerary: 2-5 bounds flown in order. When present it
            REPLACES origin/destination/departure_date/return_date (those may be
            omitted, and return_date must not be used). Dates must be in
            chronological order and each leg's origin and destination must
            differ. One offer covers the whole itinerary.

            Multi-city itineraries book like any other; see the note on
            /v1/shopping/offers about partner-operated connections.
          items: { $ref: "#/components/schemas/SearchLeg" }

    SearchLeg:
      type: object
      required: [origin, destination, departure_date]
      properties:
        origin: { type: string, example: MAD, description: 3-letter IATA airport code }
        destination: { type: string, example: JFK, description: 3-letter IATA airport code }
        departure_date: { type: string, example: "2026-10-20", description: ISO date YYYY-MM-DD }

    SearchMeta:
      type: object
      properties:
        total: { type: integer }
        airlines:
          type: array
          items: { type: string }
        sources:
          type: array
          items: { type: string }
        min_price: { type: number, format: double }

    SearchResponse:
      type: object
      properties:
        search_id: { type: string, example: srch_01J8ZC3AC5N8XW }
        expires_at: { type: string, format: date-time, description: When the cached results expire }
        offers:
          type: array
          nullable: true
          description: |
            NULL rather than an empty array when the route filter dropped every
            connector before calling an airline (a pair the carrier does not
            fly). Guard before reading a length.
          items: { $ref: "#/components/schemas/Offer" }
        meta:
          allOf: [{ $ref: "#/components/schemas/SearchMeta" }]
          description: |
            KNOWN GAP: the search handler never populates this block. It always
            comes back {"total": 0, "airlines": null} regardless of results —
            count `offers` yourself.

    OfferPriceRequest:
      type: object
      required: [offer_ids]
      properties:
        offer_ids:
          type: array
          items: { type: string }
          description: One or more offer IDs from AirShopping
        airline:
          type: string
          description: Optional IATA code; inferred from the first offer ID when omitted

    # ── Orders ───────────────────────────────────────────────────────────────
    PaymentInfo:
      type: object
      description: |
        How the booking is paid — by the AGENCY, to the CARRIER, with the
        agency's own card. Norba is not the merchant of record and holds none
        of this money: the traveller pays the agency on the agency's own
        checkout, and Norba invoices the agency for its own fees monthly.

        Card data is forwarded to the airline for the lifetime of the request
        and never persisted.
      properties:
        method: { type: string, enum: [CARD, TRANSFER, WALLET] }
        currency: { type: string, description: ISO 4217 }
        amount:
          type: number
          format: double
          description: |
            The total the agency is charging for this booking, in `currency`.

            Only a FLOOR is enforced: it must be at least the carrier's own
            price for the offer plus the extras sold beside it, as the server
            has it snapshotted, allowing a rounding cent. Less than that is
            refused with 400 "payment.amount is less than the airline's price
            for this offer", before the carrier is called.

            Anything ABOVE the floor is the agency's margin, and Norba does not
            care where it came from — a commission rule configured here, or a
            markup the agency applied in its own checkout and never declared.
            The carrier is tendered its own price either way; the margin is
            withheld and never reaches the airline.
        card: { $ref: "#/components/schemas/CardInfo" }
        authentication:
          allOf: [{ $ref: "#/components/schemas/PaymentAuthentication" }]
          description: |
            Optional. Meaningless without `card`, and refused without one with
            400 "payment.authentication needs a card to authenticate".

    CardInfo:
      type: object
      description: Raw payment-card data (PCI-scoped, request lifetime only).
      required: [number, holder, expiry_month, expiry_year]
      properties:
        number: { type: string, minLength: 12, maxLength: 19, description: Digits only }
        holder: { type: string, maxLength: 64 }
        expiry_month: { type: integer, minimum: 1, maximum: 12 }
        expiry_year: { type: integer, example: 2030, description: This year or up to 20 years out }
        cvv:
          type: string
          minLength: 3
          maxLength: 4
          description: |
            OPTIONAL. A carrier that authorises an authenticated or
            exemption-eligible transaction commonly does not ask for a security
            code, and sending one where it is not needed is gratuitous
            exposure. Send it only where the carrier requires it. Digits only.
        brand: { type: string, maxLength: 16, example: VISA }

    PaymentAuthentication:
      type: object
      description: |
        The outcome of a Strong Customer Authentication (3-D Secure) challenge
        the AGENCY's own provider ran before it called us.

        Norba never authenticates a cardholder: there is no cardholder present
        on a server-to-server booking call, and these tokens are bound to one
        transaction, so they can be neither minted here nor reused from another
        authentication. One block covers both protocol versions, because
        carriers name the same values differently.

        Omit it entirely where the transaction is exempt — a commercial card
        under a corporate payment process is the usual case for an agency
        paying with its own card.

        NOT EVERY CARRIER CAN CARRY IT. American Airlines' channel cannot, so a
        booking that sends this object to AA is refused with 422 rather than
        having it silently dropped.

        `authentication_value` and `xid` are credentials: like the card, they
        are redacted from request logs and never persisted.
      required: [version, authentication_value, eci, transaction_status]
      properties:
        version:
          type: string
          enum: ["1", "2"]
          description: 3-D Secure protocol version.
        authentication_value:
          type: string
          maxLength: 64
          description: The cryptogram — CAVV under version 1, AuthenticationValue under version 2. Base64 or hex.
        eci:
          type: string
          minLength: 2
          maxLength: 2
          description: Electronic Commerce Indicator returned by the directory server. Two digits.
        transaction_status:
          type: string
          enum: [Y, N, U, A, C, R]
          description: The issuer's verdict.
        authentication_status:
          type: string
          maxLength: 1
          description: Status from the results request after a challenge, where the carrier asks for it separately.
        ds_transaction_id:
          type: string
          maxLength: 64
          description: Directory-server exchange id. REQUIRED when version is "2".
        xid:
          type: string
          maxLength: 64
          description: Directory server's transaction identifier. REQUIRED when version is "1".
        cavv_algorithm:
          type: string
          maxLength: 2
          description: Algorithm behind the cryptogram. Version 1.
        channel:
          type: string
          maxLength: 4
          description: Payment transaction channel code, where a carrier asks for one. Version 2.

    CreateOrderRequest:
      type: object
      required: [offer_id, passengers]
      properties:
        offer_id: { type: string, description: Canonical offer ID from AirShopping/OfferPrice }
        airline: { type: string, description: Optional IATA code; inferred from offer_id when omitted }
        passengers:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/Passenger" }
        contact: { $ref: "#/components/schemas/Contact" }
        payment:
          allOf: [{ $ref: "#/components/schemas/PaymentInfo" }]
          description: |
            Omit entirely to create an UNPAID HOLD, which is the normal agency
            flow: the order comes back `confirmed` with `expires_at` set to the
            airline's payment time limit (typically three days with AA). Settle
            it later through POST /v1/orders/{orderID}/commit.
        pnr: { type: string, description: Optional existing PNR to attach to }
        seats:
          type: array
          description: |
            Seats sold WITH the flight rather than added afterwards, taken from
            GET /v1/offers/{offerID}/seats. Their à-la-carte offer references
            belong to this offer's shopping context, which is the only point at
            which the airline still recognises them: adding a seat once the order
            exists means re-pricing against passenger and segment IDs the airline
            has by then reassigned, and paid-seat assignment on an existing order
            is not available on every carrier.

            segment_id, row and column are enough — Norba resolves the seat's
            offer item from the seat map at booking.
          items: { $ref: "#/components/schemas/Seat" }
        ancillaries:
          type: array
          description: |
            Ancillaries sold with the flight, taken from
            GET /v1/offers/{offerID}/services. Same reasoning as `seats`.
          items: { $ref: "#/components/schemas/Ancillary" }

    ChangeRequest:
      type: object
      description: Canonical post-booking modification.
      required: [action]
      properties:
        action:
          type: string
          enum: [ticketing, ancillaries, reissue, cancel_segments, complete_payment]
        segment_ids:
          type: array
          description: Segments the action applies to, where relevant.
          items: { type: string }
        ancillaries:
          type: array
          items: { $ref: "#/components/schemas/Ancillary" }
        seats:
          type: array
          description: |
            Assigning a PAID seat to an order that already exists is currently
            refused by the AA gateway with "[230000197] Referenced ID not found
            in message". Sell seats inside POST /v1/orders instead.
          items: { $ref: "#/components/schemas/Seat" }
        payment: { $ref: "#/components/schemas/PaymentInfo" }

    CommitRequest:
      type: object
      description: Ancillaries + seats + payment for the upsell commit.
      properties:
        ancillaries:
          type: array
          items: { $ref: "#/components/schemas/Ancillary" }
        seats:
          type: array
          items: { $ref: "#/components/schemas/Seat" }
        payment: { $ref: "#/components/schemas/PaymentInfo" }

    ReShopRequest:
      type: object
      description: |
        "Find me alternative flights for this order". Changes nothing — apply a
        result with PUT /v1/orders/{orderID} and action "reissue".

        The two dates are RFC 3339 TIMESTAMPS, not plain YYYY-MM-DD calendar
        dates, because they decode into a time value.
      properties:
        new_departure_date: { type: string, format: date-time, nullable: true }
        new_return_date: { type: string, format: date-time, nullable: true }
        new_origin: { type: string, description: IATA airport code }
        new_destination: { type: string, description: IATA airport code }
        partial_cancel:
          type: boolean
          description: Also drop the segments in `segment_ids` if the new itinerary is accepted.
        segment_ids:
          type: array
          items: { type: string }

    Ticket:
      type: object
      properties:
        id: { type: string }
        ticket_number: { type: string, example: "075-1234567890" }
        passenger_id: { type: string }
        status: { type: string, enum: [issued, void, refunded, exchanged, flown, open] }
        issued_at: { type: string, format: date-time }
        coupon_status: { type: string }

    Payment:
      type: object
      properties:
        id: { type: string }
        method: { type: string, enum: [card, iata_bsp, cash, bank_transfer, wallet, other] }
        status: { type: string, enum: [pending, authorized, captured, failed, refunded] }
        amount: { type: number, format: double }
        currency: { type: string }
        reference: { type: string }
        processed_at: { type: string, format: date-time }

    OrderEvent:
      type: object
      description: Audit-log entry for an order.
      properties:
        id: { type: string }
        order_id: { type: string }
        event_type:
          type: string
          enum: [created, confirmed, ticketed, ancillary_added, segment_changed, cancelled, refund_requested, refunded, disrupted, reprotected, voided]
        occurred_at: { type: string, format: date-time }
        actor: { type: string, enum: [traveler, agency, system, airline] }
        payload: {}

    Order:
      type: object
      description: Durable booking record in the airline's reservation system.
      properties:
        order_id: { type: string }
        airline_order_id: { type: string, description: Airline's One Order / record ID }
        airline: { type: string, example: AA }
        pnr: { type: string, example: RJKW3S }
        ticket_number: { type: string, description: Deprecated single-ticket field }
        source_type: { type: string, enum: [ndc_direct, gds_fallback, lcc_direct] }
        status: { $ref: "#/components/schemas/OrderStatus" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, description: Ticketing deadline (TTL) }
        currency: { type: string }
        total_amount: { type: number, format: double }
        total_price: { $ref: "#/components/schemas/Money" }
        passengers:
          type: array
          items: { $ref: "#/components/schemas/Passenger" }
        slices:
          type: array
          items: { $ref: "#/components/schemas/Slice" }
        ancillaries:
          type: array
          items: { $ref: "#/components/schemas/Ancillary" }
        seats:
          type: array
          items: { $ref: "#/components/schemas/Seat" }
        tickets:
          type: array
          items: { $ref: "#/components/schemas/Ticket" }
        payments:
          type: array
          items: { $ref: "#/components/schemas/Payment" }
        history:
          type: array
          items: { $ref: "#/components/schemas/OrderEvent" }

    OrderQuote:
      type: object
      description: Price quote for modifying an existing order (a.k.a. ChangeQuote).
      properties:
        quote_id: { type: string }
        order_id: { type: string }
        original_fare: { $ref: "#/components/schemas/Money" }
        new_fare: { $ref: "#/components/schemas/Money" }
        penalty: { $ref: "#/components/schemas/Money" }
        difference: { $ref: "#/components/schemas/Money", description: "New - Original + Penalty" }
        valid_until: { type: string, format: date-time }

    CancellationQuote:
      type: object
      properties:
        quote_id: { type: string }
        order_id: { type: string }
        refund_amount: { $ref: "#/components/schemas/Money" }
        penalty_amount: { $ref: "#/components/schemas/Money" }
        valid_until: { type: string, format: date-time }

    RefundEligibility:
      type: object
      properties:
        order_id: { type: string }
        eligible: { type: boolean }
        refund_amount: { type: number, format: double, nullable: true }
        refund_currency: { type: string }
        penalty_amount: { type: number, format: double, nullable: true }
        penalty_currency: { type: string }
        deadline: { type: string, format: date-time, nullable: true }
        reasons:
          type: array
          items: { type: string }

    # ── Monitoring ───────────────────────────────────────────────────────────
    AirlineHealth:
      type: object
      properties:
        airline_code: { type: string, example: AA }
        status: { type: string, example: up, description: up | degraded | down }
        last_checked: { type: string, format: date-time }
        latency_ms: { type: integer, example: 312 }
        error_rate: { type: number, format: double, description: Lifetime error ratio 0.0-1.0 since process start }
        error_count: { type: integer }
        success_count: { type: integer }
        message: { type: string }

    # ── Aviation Data (envelope) ─────────────────────────────────────────────
    APIErr:
      type: object
      properties:
        code: { type: string, example: not_found }
        message: { type: string }

    ListMeta:
      type: object
      description: Meta block for aviation list responses.
      properties:
        request_id: { type: string }
        page: { type: integer }
        limit: { type: integer }
        count: { type: integer, description: Items in this page }
        total: { type: integer, description: Total matching before pagination }
        last_updated: { type: string, description: RFC3339 }
        airport_iata: { type: string, description: Present when the list is scoped to one airport }
        airport_icao: { type: string, description: Present when the list is scoped to one airport }

    ItemMeta:
      type: object
      description: Meta block for aviation single-resource responses.
      properties:
        request_id: { type: string }

    Envelope:
      type: object
      description: |
        Canonical wrapper for every /v1/aviation and /v1/reference response.
        `data` is a list or a single resource, `meta` is ListMeta or ItemMeta,
        and `errors` is non-empty on failures (transport errors also use this
        envelope, with `data: null`).
      properties:
        data:
          nullable: true
          description: Resource or list of resources (null on error)
        meta:
          description: ListMeta for lists, ItemMeta for single resources
          oneOf:
            - $ref: "#/components/schemas/ListMeta"
            - $ref: "#/components/schemas/ItemMeta"
        errors:
          type: array
          items: { $ref: "#/components/schemas/APIErr" }

    Airport:
      type: object
      description: Enriched airport record (OurAirports superset).
      properties:
        id: { type: string }
        name: { type: string, example: Adolfo Suárez Madrid-Barajas Airport }
        iata_code: { type: string, example: MAD }
        icao_code: { type: string, example: LEMD }
        city: { type: string }
        country: { type: string }
        region: { type: string, description: ISO 3166-2 subdivision }
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }
        elevation_ft: { type: integer, nullable: true }
        timezone: { type: string, example: Europe/Madrid }
        airport_type:
          type: string
          enum: [large_airport, medium_airport, small_airport, heliport, seaplane_base, closed]
        scheduled_service: { type: boolean }
        runways_count: { type: integer }

    AviationAirline:
      type: object
      description: |
        Enriched airline record. Only the main fields are documented; the
        record also carries OPTD provenance fields (name variants, alliance
        memberships, validity dates, source files, ...) when available.
      properties:
        id: { type: string }
        name: { type: string, example: British Airways }
        legal_name: { type: string }
        commercial_name: { type: string }
        iata_code: { type: string, example: BA }
        icao_code: { type: string, example: BAW }
        iata_numeric_code: { type: integer, nullable: true }
        callsign: { type: string, example: SPEEDBIRD }
        country: { type: string }
        status: { type: string, description: active | inactive | defunct }
        alliance: { type: string, example: oneworld }
        hubs:
          type: array
          items: { type: string }
        website: { type: string }
        logo_url: { type: string }
        alternative_names:
          type: array
          items: { type: string }
      additionalProperties: true

    AircraftType:
      type: object
      description: |
        Aircraft characteristics record (FAA Aircraft Characteristics Database
        superset). Only the main fields are documented; many more FAA-native
        dimensional and wake-category fields are present when known.
      properties:
        icao_type_code: { type: string, example: A320 }
        faa_designator: { type: string }
        manufacturer: { type: string, example: Airbus }
        model: { type: string, example: A320-232 }
        description: { type: string }
        physical_class_engine: { type: string }
        num_engines: { type: integer, nullable: true }
        engine_count: { type: integer, nullable: true, description: Legacy alias of num_engines }
        engine_type: { type: string, description: jet | turboprop | piston | electric }
        approach_speed_knot: { type: number, format: double, nullable: true }
        wingspan_ft_with_winglets_sharklets: { type: number, format: double, nullable: true }
        length_ft: { type: number, format: double, nullable: true }
        mtow_lb: { type: number, format: double, nullable: true }
        icao_wtc: { type: string }
        wake_turbulence_category: { type: string, description: L | M | H | J }
        aircraft_class: { type: string, description: narrowbody | widebody | regional | turboprop }
        registration_count: { type: integer, nullable: true }
        last_update: { type: string, format: date-time, nullable: true }
      additionalProperties: true

    Runway:
      type: object
      properties:
        id: { type: string }
        length_ft: { type: integer }
        width_ft: { type: integer }
        surface: { type: string, example: ASP }
        le_ident: { type: string, example: 14L }
        he_ident: { type: string, example: 32R }
        le_latitude: { type: number, format: double }
        le_longitude: { type: number, format: double }
        he_latitude: { type: number, format: double }
        he_longitude: { type: number, format: double }
        lighted: { type: boolean }
        closed: { type: boolean }

    Frequency:
      type: object
      properties:
        type: { type: string, example: TWR }
        description: { type: string }
        frequency_mhz: { type: number, format: double, example: 118.15 }

    Navaid:
      type: object
      properties:
        ident: { type: string, example: BRA }
        name: { type: string }
        type: { type: string, example: VOR-DME }
        frequency_khz: { type: number, format: double }
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }

    Source:
      type: object
      description: Upstream data provider used by the Aviation Data API.
      properties:
        id: { type: string, example: ourairports }
        name: { type: string, example: OurAirports }
        source_type: { type: string, example: open_data }
        family: { type: string }
        runtime_kind: { type: string }
        url: { type: string }
        license: { type: string, example: CC0 }
        license_url: { type: string }
        restrictions: { type: string }
        extraction_method: { type: string }
        schedule: { type: string }
        enabled: { type: boolean }
        status: { type: string, description: ready | not_configured | degraded }
        last_synced: { type: string, format: date-time, nullable: true }
