openapi: 3.0.3
info:
  title: ATL Transit Live Public API
  version: 0.2.0
  summary: Metered API for MARTA reliability, realtime feeds, alerts, SSE output, webhooks, and history exports.
  description: |
    This is the public API contract for ATL Transit Live. Responses expose ATL Transit Live enrichment: stored reliability rollups, generated delay alerts, rail delay summaries, source metadata, cursor pagination, SSE output on refreshed resources, webhooks, and history export manifests.

    ## Response format: JSON or SSE

    Snapshot endpoints return `application/json` by default. To receive the same
    payload framed as a one-shot server-sent event stream, request it in **either**
    of two ways:

    - send the header `Accept: text/event-stream`, or
    - add the query parameter `?format=sse`.

    **Precedence:** when the `Accept` header and the `format` parameter disagree, the
    `Accept` header wins. So `Accept: application/json` returns JSON even if
    `format=sse`, and `Accept: text/event-stream` returns SSE even if `format=json`.
    The `format` parameter is what decides when `Accept` is absent or unopinionated
    (e.g. `*/*`), which is the common case for browser `EventSource` and CLI clients.

    An SSE response emits a `ready` event followed by the resource event; it carries
    the same data as the JSON read and is metered identically (one API request).

    ## Export format: NDJSON or Parquet

    History exports (`/v1/history/exports`) are artifacts rather than responses, so
    their encoding is chosen **once, at creation**, by the `format` field of the
    `POST` body — not by `Accept`, and not by the `?format=` query parameter above.
    Those two negotiate the encoding of a *response*; this names the encoding of a
    *file*, and the two must not be confused. `?format=sse` on an export endpoint
    still means "frame the manifest as SSE" and says nothing about the artifact.

    - `ndjson.gz` (default) — gzipped newline-delimited JSON, available on every plan.
    - `parquet` — Apache Parquet, **Max plan only**.

    **Precedence:** there is none to resolve, and that is deliberate. The format is
    read from exactly one place, the `POST` body; the created manifest records it in
    its `format` field, and `GET .../download` serves that artifact's own media type
    (`application/vnd.apache.parquet` or `application/x-ndjson`) and a matching
    filename. An export is **never transcoded on the way out**, so the `format` on a
    manifest always describes the bytes you will actually receive.

    A non-Max key that asks for `parquet` is refused with `403 feature_not_in_plan`
    naming the plan requirement. It is **never** silently downgraded to `ndjson.gz`:
    a client that asked for Parquet and received gzipped NDJSON under a `200` has no
    way to notice.

    The same dataset and window in two formats are two separate exports with two
    manifests — requesting Parquet for a window you already hold as NDJSON creates a
    new export rather than returning the existing one.

    **Why only here.** Parquet is offered for exports and nowhere else. The
    browser-facing endpoints return small payloads — the largest is about 23 KB of
    simplified geometry — where a WASM Parquet reader would cost a browser far more
    bytes than the format could save. Exports are the opposite case: large,
    analytical, and read with tooling that already speaks Arrow. Its value here is
    the **schema**: column types and nullability are declared in the file, so a
    measurement that was never taken arrives as `null` rather than as an untyped
    JSON absence a reader has to guess about.

    ## Response headers

    Every JSON response carries these, whether or not an individual operation
    below repeats them:

    | Header | When | Meaning |
    | --- | --- | --- |
    | `X-API-Version` | always | Always `v1`. The contract version this response was served under. |
    | `X-Request-Id` | always | Correlation id, also echoed in `error.request_id`. Quote it in a support request. |
    | `Cache-Control` | always | `private, no-store` unless an operation documents otherwise. Responses are per-key, so a shared cache must not reuse one across callers. |
    | `RateLimit-Limit` | authenticated | Requests allowed in the current 5-minute burst window. |
    | `RateLimit-Remaining` | authenticated | Requests left in that window. |
    | `RateLimit-Reset` | authenticated | Seconds until the window resets. |
    | `X-RateLimit-Policy` | authenticated | Human-readable description of the applied policy. |
    | `X-Meter-Unit` | metered | The unit this request bills. See the header's own description. |

    **Use `RateLimit-Remaining` and `RateLimit-Reset` for backoff** rather than
    waiting for a `429`. The limit is a hard refusal, not an overage charge — a
    request over the line is rejected, never billed — so pacing against these is
    the difference between a working integration and a throttled one.

    ## What this document does NOT specify

    The service registers 40 operations that are deliberately absent from this
    contract. They are listed here by prefix so that finding one in a browser's
    network tab is not a discovery — it is an operation you already know is out
    of scope:

    | Prefix | Count | What it is |
    | --- | --- | --- |
    | `/v1/auth/*` | 4 | Magic-link login for the account console |
    | `/v1/keys`, `/v1/keys/{id}/*` | 5 | API-key self-service (create, rename, regenerate, revoke) |
    | `/v1/account/*` | 8 | The signed-in customer's own usage, exports and webhook endpoints |
    | `/v1/admin/*` | 20 | Staff-only operations, gated on an admin flag |
    | `/v1/billing/checkout`, `/v1/billing/portal` | 2 | Stripe redirect handoffs |
    | `/v1/billing/webhook` | 1 | Stripe → this service, signature-verified |

    **They are excluded because they cannot be used the way this document is
    written, not merely because they are internal.** Every operation specified
    below authenticates with the `bearerApiKey` scheme. Those 40 do not: the
    console surfaces require a session cookie plus a CSRF header and reject a
    bearer API key outright, and the Stripe webhook authenticates with a payload
    signature. Specifying them here would put "Try it out" buttons in this
    document that return `401` for every reader holding the credential the
    document tells them to use — a worse outcome than their absence, and a
    promise of support that is not offered.

    They are also not a stable surface: they exist to serve the first-party
    account console and change with it, with no deprecation window. Build against
    the operations below, which do carry one.

    `GET /v1/billing/plans` is the one member of those prefixes that IS specified
    below, because it is genuinely public — unauthenticated, stable, and the same
    catalogue the pricing page renders.
  contact:
    name: ATL Transit Live
    url: https://atltransit.live/developers
# Plan hard limits. Billing is FLAT-TIER: an over-limit request is refused, it is
# never billed as overage, so there is no "included requests per month" allowance
# -- the enforced dimensions are a 5-minute burst window and a daily cap.
#
# These values mirror the effective-dated rows seeded by migration
# 0023_pricing_tiers_reshape.sql and the `tiers.Fallback` map in
# internal/tiers/tiers.go; the runtime always reads api_tier_limits_current, so
# treat this block as documentation, not as the source of truth. Prices are the
# USD amounts provisioned by infra/scripts/staging/provision_stripe.py (annual is
# 20% off twelve months). Every new paid subscription starts with a card-required
# 7-day free trial (checkoutTrialDays in internal/handlers/billing.go).
x-atltransit-tiers:
  free:
    price_cents_month: 0
    rate_limit_per_5min: 20
    rate_limit_per_day: 300
    max_sse_connections: 0
    max_webhook_endpoints: 0
    max_query_limit: 20
    max_api_keys: 1
    bulk_downloads: false
    historical_stop_analytics: false
  regular:
    price_cents_month: 499
    price_cents_year: 4790
    rate_limit_per_5min: 600
    rate_limit_per_day: 50000
    max_sse_connections: 2
    max_webhook_endpoints: 5
    max_query_limit: 100
    max_api_keys: 5
    bulk_downloads: false
    historical_stop_analytics: false
  max:
    price_cents_month: 1499
    price_cents_year: 14390
    rate_limit_per_5min: 6000
    rate_limit_per_day: 1000000
    max_sse_connections: 25
    max_webhook_endpoints: 25
    max_query_limit: 1000
    max_api_keys: 25
    bulk_downloads: true
    historical_stop_analytics: true
# A relative server URL is resolved against the location this document was
# served from (OAS 3.1 §4.8.5), so Swagger UI "Try it out" targets whichever
# host you are browsing -- atltransit.live, test.atltransit.live, marta.fail,
# or localhost -- with no per-environment configuration and no hardcoded base
# URL. Do NOT reintroduce absolute server entries.
#
# The path is `/api` and the operation paths below are `/v1/...`, which
# concatenate to the PUBLIC spelling `/api/v1/...`. `/api` is a prefix the edge
# owns, not a path the Go service serves: haproxy's public_api backend strips it
# with `replace-path ^/api(/v1(/.*)?)$ \1`, and the Astro dev server's Vite proxy
# does the same, so the service still binds its routes at `/v1`. Keep the two
# halves where they are -- moving `/api` into the path keys would break the
# generated router, and dropping it would make every "Try it out" call 404,
# because bare `/v1` is routed nowhere on any host.
servers:
  - url: /api
    description: The host serving this document
security:
  - bearerApiKey: []
tags:
  - name: Service
    description: Service metadata and credential checks. Not metered.
  - name: Rail
  - name: Bus
  - name: Stops
    description: Authenticated current stop data and bounded Max aggregate history.
  - name: Streetcar
  - name: System
    description: >-
      Network-wide aggregates. The scheduled-versus-cancelled metric is bus only,
      because cancellation is the one signal MARTA does not publish for rail or
      streetcar. Rail itself is fully covered elsewhere — measured punctuality at
      `GET /v1/rail/on-time` and live positions at `GET /v1/rail/realtime`.
  - name: Alerts
    description: >-
      Stored service alerts across every mode. Two pipelines feed this: MARTA's own
      emailed alerts (`source=email`, LLM-structured) and the realtime delay state
      machine (`source=marta_rt`). They are merged and de-duplicated -- an email
      alert for a route suppresses the realtime alert for the same route.
  - name: Lines
    description: Public, unauthenticated rider feedback for a single line.
  - name: History
  - name: Webhooks
  - name: Events
paths:
  # --------------------------------------------------------------------------
  # Service + public endpoints.
  #
  # These three operations are DOCUMENTATION-ONLY: they are hand-registered on
  # the mux in cmd/server/main.go and are listed in
  # oapi-codegen.yaml's output-options.exclude-operation-ids so the generated
  # std-http-server router does NOT also register them. http.ServeMux panics on
  # a duplicate pattern, which would be a startup crash. If you rename an
  # operationId below, rename it in oapi-codegen.yaml too.
  # --------------------------------------------------------------------------
  /v1/health:
    get:
      tags: [Service]
      summary: Verify an API key and read its resolved plan limits
      operationId: getHealth
      description: |
        Authenticated credential check. Verifies the bearer API key and reports the
        resolved plan limits plus a **non-incrementing** rate-limit snapshot. This
        endpoint is not metered and does not consume rate-limit budget.

        `key` and `rate_status` are present only for a database-backed API key.
        (The separate `/healthz` liveness probe is an infrastructure endpoint and
        is intentionally not part of this contract.)
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The key is valid; resolved limits and a rate snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [status, authenticated, limits, db_backed]
                properties:
                  status:
                    type: string
                    enum: [ok]
                  authenticated:
                    type: boolean
                  key:
                    type: object
                    description: Present only for a database-backed key.
                    required: [id, plan]
                    properties:
                      id:
                        type: string
                        description: API key id.
                      plan:
                        type: string
                        description: Plan slug resolved for the key.
                  limits:
                    allOf:
                      - $ref: '#/components/schemas/PlanLimits'
                    description: Plan limits resolved for this key.
                  rate_status:
                    type: object
                    description: |
                      Non-incrementing snapshot of the current rate-limit counters.
                      Present only for a database-backed key, and omitted if the
                      snapshot read fails.
                    required: [remaining_5min, remaining_day, resets_at]
                    properties:
                      remaining_5min:
                        type: integer
                      remaining_day:
                        type: integer
                      resets_at:
                        type: string
                        format: date-time
                        description: RFC 3339 UTC instant at which the 5-minute window resets.
                  db_backed:
                    type: boolean
                    description: Whether the key was resolved against the database.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: |
            Authentication backend unavailable, or the deployment has no database
            configured (`upstream_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/openapi.yaml:
    get:
      tags: [Service]
      summary: Fetch this OpenAPI document
      operationId: getOpenapiDocument
      description: |
        Returns this OpenAPI 3.1 document as YAML. Unauthenticated. Served with
        `Cache-Control: public, max-age=300`.
      security: []
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The OpenAPI document.
          headers:
            Cache-Control:
              description: Always `public, max-age=300`.
              schema:
                type: string
          content:
            application/yaml:
              schema:
                type: string
                description: The OpenAPI 3.1 document, as YAML.
  /v1/billing/plans:
    get:
      tags: [Service]
      summary: List the plan catalogue and each plan's hard limits
      operationId: getBillingPlans
      description: |
        The public pricing catalogue: every plan slug, its price, and the hard
        limits that plan enforces. Unauthenticated, not metered, and served with
        `Cache-Control: public, max-age=60`.

        This is the authoritative machine-readable form of the limits table in
        this document's introduction — read it here rather than parsing the prose,
        because the runtime resolves limits from the database and this endpoint
        reflects that resolution.

        **Prices are integer minor units (US cents), and `null` is not free.**
        `amounts.month` and `amounts.year` are `null` when no active price is
        provisioned for that plan and interval — which is the case for the `free`
        plan, and is also what a price catalogue that failed to load reports. The
        endpoint degrades to null amounts rather than returning `0` or a `5xx`, so
        that a caller never reads an unprovisioned price as a free one. Treat
        `null` as "price unknown, do not display", not as zero.

        Every other billing operation is a session-cookie or Stripe-signed
        surface and is deliberately outside this contract; see "What this document
        does NOT specify".
      security: []
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The plan catalogue, in display order (free, regular, max).
          headers:
            Cache-Control:
              description: Always `public, max-age=60`.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [plans]
                properties:
                  plans:
                    type: array
                    items:
                      $ref: '#/components/schemas/BillingPlan'
              # A Media Type Object carries NAMED examples in 3.0, unlike a
              # Schema Object, which is why the paid-plan shape lives here and
              # the free plan stays on the schema. Both cases matter for the
              # same reason: `null` amounts mean "no price provisioned", and a
              # reader who has only ever seen the paid shape may assume a
              # number is always there.
              examples:
                free_plan:
                  summary: The free plan — null prices, not zero prices
                  value:
                    plans:
                      - plan_slug: free
                        currency: usd
                        amounts:
                          month: null
                          year: null
                        limits:
                          rate_per_5min: 20
                          rate_per_day: 300
                          max_sse_connections: 0
                          max_webhook_endpoints: 0
                          max_query_limit: 20
                          max_api_keys: 1
                          bulk_downloads: false
                          historical_stop_analytics: false
                paid_plan:
                  summary: A paid plan — integer minor units (US cents)
                  value:
                    plans:
                      - plan_slug: max
                        currency: usd
                        amounts:
                          month: 1499
                          year: 14390
                        limits:
                          rate_per_5min: 6000
                          rate_per_day: 1000000
                          max_sse_connections: 25
                          max_webhook_endpoints: 25
                          max_query_limit: 1000
                          max_api_keys: 25
                          bulk_downloads: true
                          historical_stop_analytics: true
  /v1/lines/{id}/feedback:
    get:
      tags: [Lines]
      summary: Get rider ratings and approved comments for a line
      operationId: getLineFeedback
      description: |
        Public, unauthenticated aggregate rider feedback for one line: the mean
        rating, the number of ratings, and up to 10 moderator-approved comments
        (most recent first). Not metered and not key-gated.

        Only approved comments are returned; comments awaiting moderation are not
        exposed here.
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: |
            Line identifier. Accepts a canonical entity id
            (`RAIL_SERVICE_RED`, `BUS_ROUTE_110`, `STREETCAR_LINE_DOWNTOWN`) or a
            short name (`RED`, `BLUEGREEN`, `110`, `A`, `ATLSC`). Maximum 64
            characters.
          schema:
            type: string
            maxLength: 64
      responses:
        '200':
          description: Aggregate feedback for the line.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LineFeedbackResponse'
        '400':
          description: The `id` is empty or longer than 64 characters (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The `id` did not resolve to a known line (`not_found`, message `Unknown line`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: |
            Feedback storage is unavailable — the deployment has no database, or
            the line lookup / aggregate read failed (`upstream_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [Lines]
      summary: Submit a rider rating, and optionally a comment, for a line
      operationId: postLineFeedback
      description: |
        Public, unauthenticated rider feedback. Not metered and not key-gated;
        writes are rate-limited per IP instead.

        **The rating and the comment are not equals.** A rating is an UPSERT
        keyed on (line, client) — submitting again replaces your previous rating
        rather than adding a second one. A comment is an insert, is held
        `pending` for out-of-band moderation, and does NOT appear in the
        response; only approved comments are ever returned by the `GET`.

        **Retries are idempotent if you make them so.** Send a UUID
        `submission_id` and a repeat of the same submission is recognised as a
        replay: it is neither stored twice nor counted against the comment
        throttle. Omit it and the server generates one, which means a retried
        request IS a second comment.

        **A throttled comment still saves the rating.** Past the per-IP,
        per-line comment allowance the response is `429 rate_limited` with
        `Retry-After` — but the rating was already committed in the same
        transaction. The 429 means "the comment was not stored", never "nothing
        was stored".

        **No PII is retained.** The client IP is salted and hashed for the
        upsert key and throttle; the raw address is never stored.
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: >-
            Line identifier, resolved exactly as on the `GET` — a canonical
            entity id or a short name. Maximum 64 characters.
          schema:
            type: string
            maxLength: 64
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LineFeedbackSubmission'
      responses:
        '200':
          description: >-
            Feedback recorded. The body is the same aggregate the `GET` returns,
            already including your rating — but NOT your comment, which is
            awaiting moderation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LineFeedbackResponse'
        '400':
          description: >-
            The body is not JSON, `rating` is not a whole number from 1 to 5, or
            the `id` is empty or over 64 characters (`invalid_request`). The body
            is capped at 4 KiB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The `id` did not resolve to a known line (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: >-
            Either the per-IP write rate limit, or the durable per-IP per-line
            comment allowance, was reached (`rate_limited`). In the latter case
            **the rating was still saved** and only the comment was dropped;
            `Retry-After` gives the seconds until the allowance resets.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            Feedback storage is unavailable, or the write failed
            (`upstream_unavailable`). The write is one transaction, so a failure
            leaves neither the rating nor the comment stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  # --------------------------------------------------------------------------
  # Cross-mode alerts.
  #
  # The dashboard's quickstart curl and the /developers endpoint inventory have
  # both pointed developers at GET /v1/alerts?mode=rail since the account panel
  # shipped. It did not exist. `mode` is a FILTER here, not a path segment,
  # exactly as those examples write it.
  # --------------------------------------------------------------------------
  /v1/alerts:
    get:
      tags: [Alerts]
      summary: List service alerts across every mode
      description: >-
        Stored rail, bus, streetcar and station-facility alerts in one list, from
        both pipelines, with the same filters as the per-mode lists plus an optional
        `mode`. This is a RECORD LIST, so it is paginated by cursor and its page
        size is bounded by your plan's `max_query_limit`; a page is a smaller true
        answer and the next one is one cursor away. Read `cross_mode_note` before
        comparing anything between modes.
      operationId: listAlerts
      parameters:
        - $ref: '#/components/parameters/Format'
        - $ref: '#/components/parameters/AlertMode'
        - $ref: '#/components/parameters/AlertStatus'
        - name: route_or_line
          in: query
          schema:
            type: string
          description: Optional route or line filter. Comma-delimited list accepted (e.g. RED,GOLD or 2,39).
        - $ref: '#/components/parameters/AlertStation'
        - $ref: '#/components/parameters/AlertSource'
        - $ref: '#/components/parameters/AlertStart'
        - $ref: '#/components/parameters/AlertEnd'
        - $ref: '#/components/parameters/AlertAffectedID'
        - $ref: '#/components/parameters/AlertImpactType'
        - $ref: '#/components/parameters/AlertQ'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Matching alerts across every mode, as JSON or SSE.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: A filter could not be honoured as written (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/routes/{route_id}/stops:
    get:
      tags: [Stops]
      summary: List current-feed stops and MARTA marker predictions for a route
      operationId: getRouteStops
      description: |
        Requires the `read:routes` API-key scope and is available on every plan.
        Returns current static stop geometry plus the nearest fresh MARTA
        prediction per direction. Missing, stale, cancelled, or unmapped data is
        represented by an empty `current_predictions` list or `unknown`; it is
        never inferred to be on time. The route response never scans raw history.
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, minLength: 1, maxLength: 128 }
      responses:
        '200':
          description: GeoJSON stop collection for the current static feed.
          headers:
            X-Meter-Unit: { $ref: '#/components/headers/MeterUnit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RouteStopsResponse' }
        '400':
          description: Invalid route identifier (`invalid_request`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The key lacks `read:routes` (`scope_not_granted`). No stop query or metering occurs.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: The route is absent from the current static feed (`not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/stops/{stop_id}/predictions:
    get:
      tags: [Stops]
      summary: List current MARTA and ATL predictions for one route stop
      operationId: getStopPredictions
      description: |
        Requires `read:routes` and is available on every plan. `route_id` is
        mandatory so a stop on another route cannot be mixed into the result.
        This is an opaque-cursor page over the ordinary current projection, not
        a raw observation stream. ATL accuracy remains `null` with
        `insufficient_actuals` until an independently observed cohort is large
        enough; a model prediction is never described as measured accuracy.
      parameters:
        - name: stop_id
          in: path
          required: true
          schema: { type: string, minLength: 1, maxLength: 128 }
        - name: route_id
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 128 }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Cursor-paged current prediction projection.
          headers:
            X-Meter-Unit: { $ref: '#/components/headers/MeterUnit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StopPredictionsResponse' }
        '400':
          description: Missing/invalid route context or cursor (`invalid_request` or `invalid_cursor`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The key lacks `read:routes` (`scope_not_granted`). No current query or metering occurs.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: The current feed does not place this stop on this route (`not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/stops/{stop_id}/prediction-history:
    get:
      tags: [Stops]
      summary: Get bounded daily prediction-adherence aggregates (Max only)
      operationId: getStopPredictionHistory
      description: |
        Requires the `read:history` scope and the distinct Max-only
        `historical_stop_analytics` capability. The scope check happens before
        tier/rate/query/meter work; a Max key without it receives
        `403 scope_not_granted`. Free or Regular keys carrying the scope receive
        `403 feature_not_in_plan` before the aggregate query and are not metered.

        **Aggregates only; no raw event stream.** The only accepted periods are
        inclusive Eastern-time `7d`, `30d`, and `90d`. Responses read the daily
        aggregate view and contain no raw per-poll records, source/R2/database
        ids, trip ids, vehicle ids, or arbitrary date scans. Null accuracy means
        unmeasured/insufficient, never zero error.
      parameters:
        - name: stop_id
          in: path
          required: true
          schema: { type: string, minLength: 1, maxLength: 128 }
        - name: route_id
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 128 }
        - name: period
          in: query
          required: true
          schema: { type: string, enum: [7d, 30d, 90d] }
          description: Exact inclusive period ending today in America/New_York.
      responses:
        '200':
          description: Daily records and a bounded period aggregate.
          headers:
            X-Meter-Unit: { $ref: '#/components/headers/MeterUnit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StopPredictionHistoryResponse' }
        '400':
          description: Unsupported period or invalid route/stop context (`invalid_request`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`scope_not_granted` or `feature_not_in_plan`; no aggregate content is returned.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: The current feed does not place this stop on this route (`not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/rail:
    get:
      tags: [Rail]
      summary: List rail route metadata
      operationId: listRail
      parameters:
        - $ref: '#/components/parameters/Days'
      responses:
        '200':
          description: Rail line directory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModeRoutesResponse'
        '400':
          description: '`days` is outside 1-365, or is not an integer (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: '`days` was accepted but the upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/rail/realtime:
    get:
      tags: [Rail]
      summary: List realtime rail arrivals
      operationId: listRailRealtime
      description: |
        **Uncollapsed rail poll history**, plus delay aggregates by line and by
        station.

        The realtime feed reports every train against every station ahead of it
        roughly every 60 seconds, so a row here is one *poll reading*, not one
        train. The same train appears many times. For "what is true right now",
        one row per `(line, station, direction, train_id)`, use
        `GET /v1/rail/positions`.

        **`page_info.total_available` can be a LOWER BOUND on this endpoint, not
        a total.** The window is fetched from upstream at a hard cap of 500 rows
        before any filter is applied, so when that cap is reached
        `total_available` counts what was fetched and filtered, never what
        matched. Measured against the development database on 2026-07-28, a
        30-minute window held 687 rows against that 500-row ceiling. Do not
        present it as a count of the window, and do not derive a page count from
        it. `GET /v1/rail/positions` collapses the whole window upstream and its
        `count` is a true total.

        **Branch on `total_note`, not on this paragraph.** The response carries
        the caveat itself: `total_note` is a non-null string exactly when the cap
        was reached and the total is therefore a lower bound, and `null` when the
        total is exact.

        **Unmeasured is null.** `delay_seconds` is `null`, never `0`, when the
        observation carried no reading, and `delay_status` is then `unknown`,
        never `normal`. Around half of observations carry no reading, so treat
        this as the common case rather than an edge case. The exact share moves
        with the feed and the window — read it from `observations_with_delay` out
        of `observations_total` in the summaries below rather than assuming a
        fixed ratio.
      parameters:
        - $ref: '#/components/parameters/Format'
        - $ref: '#/components/parameters/RailLine'
        - name: station
          in: query
          schema:
            type: string
          description: Optional station name filter. Comma-delimited list accepted (e.g. AIRPORT STATION,FIVE POINTS STATION).
        - name: minutes
          in: query
          description: Lookback window in minutes. Outside 1-120 is a `400 invalid_request`.
          schema:
            type: integer
            minimum: 1
            maximum: 120
            default: 15
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Recent rail arrivals.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailRealtimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 1-120 (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/rail/positions:
    get:
      tags: [Rail]
      summary: Get the current rail snapshot (one row per train-at-station-direction)
      operationId: listRailPositions
      description: |
        **What is true right now**, as opposed to what was reported recently.

        `rail_realtime_observations` accumulates a full poll history: the feed
        reports every train against every station ahead of it roughly every 60
        seconds, so stored rows outnumber actual trains by around **1000×**.
        This endpoint collapses that history to the **most recent poll per
        `(line, station, direction, train_id)`** — the tuple is published in the
        response as `collapse_key` so a consumer can reproduce the grouping
        rather than guess it.

        **This is the same row set the site's own rail map reads.** The collapse
        is performed by the upstream query that serves the internal
        `/public/rail/positions`, not re-implemented here, so the two agree by
        construction for identical parameters — `count` and `with_position` on
        this response are the same numbers that endpoint reports.

        **Why this is a route rather than a `latest=true` flag on
        `GET /v1/rail/realtime`.** That route reads a hard-capped 500 rows of
        raw history, which is a truncated tail of a 30-minute window: measured
        against the development database on 2026-07-28, the full window collapses
        to 343 keys while those 500 rows contain only 190 of them. Collapsing
        there would have returned 55% of the snapshot while looking complete.
        Its `arrivals[]` record also carries neither `direction` (needed for the
        collapse key) nor `latitude` (needed for `with_position`).
        `GET /v1/rail/realtime` remains the endpoint for **uncollapsed poll
        history**; the two answer different questions over the same rows.

        **Every count names its unit.** `count` is collapsed observation ROWS in
        the window. `trains` is distinct physical trains, counted as distinct
        `(line, train_id)` pairs because MARTA recycles train numbers per line.
        These differ by orders of magnitude and must never be substituted for one
        another. `returned` is the size of this page. `count_definitions` repeats
        all of this in the payload.

        **Unmeasured is null.** Most rows carry a latitude/longitude fix but
        some do not, and around half carry no `delay_seconds` reading; both
        shares move with the feed, so read `with_position` against `count` in
        this response rather than assuming a fixed ratio. Rows without a fix are
        still returned — an arrivals board needs
        them — with `latitude`/`longitude` as `null`, never `0`, which would
        place every unlocated train off the coast of Africa. `delay_status` is
        `unknown`, never `normal`, when no reading was taken.

        **`waiting_seconds` is frozen, not live.** It is a countdown captured at
        that row's `observed_at`. Age it forward by `now - observed_at` before
        displaying it; treating it as current makes every station appear to have
        a train arriving. Treat a row older than about 10 minutes as stale.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: line
          in: query
          description: >-
            Single rail line filter: RED, GOLD, BLUE, or GREEN (case-insensitive).
            Unlike `GET /v1/rail/realtime` this takes ONE line, not a
            comma-delimited list, because the filter is applied by the upstream
            snapshot query rather than after the fact — which is what keeps the
            returned set identical to the site's. Omit for every line. An
            unrecognised value is a `400 invalid_request`, never a silent
            empty result.
          schema:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
        - name: minutes
          in: query
          description: >-
            Lookback window. Only observations from the last N minutes are
            considered before collapsing. A live feed goes stale; pair this with
            each row's `observed_at` to judge freshness rather than trusting an
            old position as current.
          schema:
            type: integer
            minimum: 1
            maximum: 180
            default: 30
        - name: limit
          in: query
          description: >-
            Page size, capped by the plan's `max_query_limit`. This is a record
            list, so it is tier-truncated; `count` always reports the whole
            window regardless, so a truncated page is visible rather than silent.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: cursor
          in: query
          description: Opaque continuation token from `page_info.next_cursor`.
          schema:
            type: string
      responses:
        '200':
          description: The current collapsed rail snapshot.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailPositionsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`line` is not a known rail line, or `minutes` is outside 1-180 (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/rail/on-time:
    get:
      tags: [Rail]
      summary: Get rail on-time performance
      operationId: listRailOnTime
      description: |
        On-time performance per rail line over a trailing window, with the
        immediately preceding equal-length window for comparison.

        **This is a different metric from bus reliability and must never be
        silently compared to it.** Bus reliability is scheduled-vs-cancelled
        trips. Rail punctuality is *measured*, from MARTA's own rail realtime
        feed: `rail_realtime_observations` carries `delay_seconds`,
        `waiting_seconds`, `is_realtime`, train ids and positions for all four
        lines. On-time performance is the share of those observations whose
        delay reading is under `on_time_threshold_seconds`.

        The **only** rail metric MARTA publishes no signal for is *cancellation*
        — which is why rail service quality is reported here as on-time
        performance rather than as scheduled-vs-cancelled. That is a gap in one
        metric, not a gap in rail coverage.

        **Coverage.** Around half of rail realtime observations carry no
        `delay_seconds` reading, and the share moves with the feed. Those are
        excluded from
        `observations_with_delay`, the denominator of `on_time_pct` — counting a
        missing reading as on time would push every line toward 100%. Report
        `observations_with_delay` out of `observations_total` rather than
        presenting the percentage as if it covered every observation.

        **Unmeasured is null.** `on_time_pct` and every delay figure are `null`
        — never `0`, and never `100` — when `observations_with_delay` is `0`. A
        line with no readings is unmeasured, not perfect.

        **The comparison arm can be absent.** When the prior window has no data,
        every `previous_*` field and `delay_trend` are `null`, **not**
        `"improving"`. That is the common case for long windows while rail
        history is still accumulating.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: line
          in: query
          description: Optional single rail line filter, case-insensitive.
          schema:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
        - name: hours
          in: query
          description: Trailing window in hours. The comparison window is the immediately preceding window of the same length.
          schema:
            type: integer
            minimum: 1
            maximum: 168
            default: 24
      responses:
        '200':
          description: Rail on-time performance per line.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailOnTimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`hours` is outside 1-168, or `line` is not a known rail line (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/rail/headways:
    get:
      tags: [Rail]
      summary: Get inferred rail headways
      operationId: listRailHeadways
      description: |
        Trailing-average headway system-wide, per line, and per platform
        (line + station + direction), over 1h / 6h / 12h / 24h windows.

        **This metric is INFERRED, not measured.** `inferred` is `true` on every
        response and is not decoration. The realtime feed contains no arrival
        events — only rolling next-arrival predictions that stop appearing once a
        train has arrived — so each arrival is reconstructed as the last observed
        poll of a run plus its remaining waiting time. Do not present these as
        observed headways.

        Every window therefore ships with `sample_count` per group, the actual
        `observation_span_hours` backing it, and the counts of what was discarded
        (`unresolved_runs`, `merged_duplicate_arrivals`, `service_gap_excluded`).

        **A window that cannot be answered says so.** Whether each window is
        answerable is decided per request from the data, not from a hardcoded
        list. A window backed by less than 90% of its own length reports
        `insufficient_history: true` with every figure `null` — it is never
        silently narrowed while still wearing its label, and a figure is never
        extrapolated. A group with fewer than `min_headway_samples` intervals is
        likewise `null`, not a confident average over two data points.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: line
          in: query
          description: Optional single rail line filter, case-insensitive.
          schema:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
        - name: station
          in: query
          description: Optional station name filter, case-insensitive (e.g. `FIVE POINTS STATION`).
          schema:
            type: string
            maxLength: 128
      responses:
        '200':
          description: Inferred rail headways per window.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailHeadwaysResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`station` is longer than 128 characters, or `line` is not a known rail line (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/rail/{line}/reliability:
    get:
      tags: [Rail]
      summary: Get rail line reliability
      operationId: getRailReliability
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: line
          in: path
          required: true
          schema:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
        - name: period
          in: query
          schema:
            type: string
            enum: [today, 7d, 30d, 90d]
            default: 30d
        - name: start
          in: query
          description: Inclusive start date (YYYY-MM-DD, America/New_York). When set, overrides period; defaults to 7 days before end.
          schema: { type: string, format: date }
        - name: end
          in: query
          description: Inclusive end date (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: breakdown
          in: query
          schema:
            type: string
            enum: [day, week, period]
      responses:
        '200':
          description: >-
            Rail reliability rollup. Empty record sets are valid when no rail
            reliability rows are available for the requested line/window — but
            only for one of the four real lines. A `line` outside the enum is a
            `400`, not an empty 200, so an empty `records` array always means
            "this line had no rows in this window" and never "no such line".
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RouteReliabilityResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: >-
            `line` is not one of RED, GOLD, BLUE, GREEN; `start` or `end` is not
            YYYY-MM-DD; or `breakdown` / `period` was given a value outside its
            enum (`invalid_request`). An unrecognized
            value is rejected rather than ignored: silently falling back to the
            daily arm would answer a different question than the one asked and
            label the answer `"breakdown": "day"`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/rail/{line}/trips/today:
    get:
      tags: [Rail]
      summary: Get a rail line's trips for a service day
      operationId: getRailTripsToday
      description: |
        Every trip scheduled on one rail line for a single service day, grouped by
        direction, with each trip's cancellation status and a summary per
        direction. This is the data behind the site's per-route "today" view.

        **Only cancellation is unavailable for rail — rail is otherwise fully
        covered here.** The four rail lines are first-class routes in the static
        GTFS feed (`gtfs_routes.route_type = 1`, alongside 81 bus routes), so the
        schedule side of this response is exact, and rail punctuality is measured
        and published separately at `GET /v1/rail/on-time` and
        `GET /v1/rail/realtime`, both built on `rail_realtime_observations`.

        What is missing is the cancellation signal specifically, so every
        cancellation-derived field here is `null`. `gtfs_rt_canceled_trips` has never contained a rail trip, and the MARTA email alert feed is bus-scoped — it publishes `bus_service` alerts keyed by route number. `total_cancelled`,
        `reliability_pct`, each direction's `cancelled`/`reliability_pct`, and
        **each trip's `is_cancelled`** are `null` — not `0`, not `false`, not
        `100.0`. The upstream computes `100.0%` and `is_cancelled: false` for
        this mode, and both are artifacts of a feed that has never carried a
        rail cancellation. Republishing them would assert, trip by trip, that
        each vehicle ran; that cannot be known from the absence of a feed.
        `cancellation_signal.available` is `false` and says why in the payload.

        **The schedule side is real.** `total_scheduled`, the directions,
        headsigns and departure times all come from the GTFS feed vintage
        effective for the requested date, and are unaffected.

        Rail service quality is published as on-time performance at `GET /v1/rail/on-time`. **That is a different metric and must never be silently compared to bus reliability.**

        **Truncation never changes the answer.** The per-direction `trips` array
        is a record enumeration and IS capped by `limit`, which is capped in turn
        by your plan's max query limit, with `trip_count` / `trips_returned` /
        `trips_truncated` disclosed per direction. Every figure above it —
        `total_scheduled`, `total_cancelled`, each direction's summary,
        `cancelled_trips` and `unmatched_cancellations` — is computed over **all**
        trips and is unaffected by the cap, so a free key still gets the correct
        answer to "how many trips today, and how many were cancelled".

        `date` defaults to today in America/New_York, which is the service day
        MARTA operates on.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: line
          in: path
          required: true
          description: 'Rail line: RED, GOLD, BLUE, or GREEN (case-insensitive).'
          schema:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
        - name: date
          in: query
          description: Service day to report (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: limit
          in: query
          description: |
            Caps the per-direction `trips` array, and is capped in turn by the
            plan's max query limit. Truncation is always disclosed by
            `trips_truncated` alongside the full `trip_count`. Summary figures are
            never affected.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 500
      responses:
        '200':
          description: The line's scheduled trips for the requested service day.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TripsTodayResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`date` is not YYYY-MM-DD, or `line` is not a known rail line (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No trips are scheduled for this line on that service day (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/rail/alerts:
    get:
      tags: [Rail]
      summary: List rail alerts
      operationId: listRailAlerts
      parameters:
        - $ref: '#/components/parameters/Format'
        - $ref: '#/components/parameters/AlertStatus'
        - name: route_or_line
          in: query
          schema:
            type: string
          description: Optional rail line filter. Comma-delimited list accepted (e.g. RED,GOLD).
        - $ref: '#/components/parameters/AlertStation'
        - $ref: '#/components/parameters/AlertSource'
        - $ref: '#/components/parameters/AlertStart'
        - $ref: '#/components/parameters/AlertEnd'
        - $ref: '#/components/parameters/AlertAffectedID'
        - $ref: '#/components/parameters/AlertImpactType'
        - $ref: '#/components/parameters/AlertQ'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Matching rail alerts as JSON or SSE.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: 'A filter could not be honoured as written (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/rail/alert-stats:
    get:
      tags: [Rail]
      summary: Per-line or per-station rail alert statistics
      description: >-
        Alert counters for every rail line, or for every rail station, over a range
        of America/New_York calendar days.


        THIS IS AN AGGREGATE, NOT A RECORD LIST. It is a closed set -- four lines or
        thirty-eight stations -- computed in full, so it is NOT truncated to your
        plan's `max_query_limit`: twenty of thirty-eight stations is not "per-station
        alert statistics", it is a different and false claim, and cutting it saves
        the database nothing. Rate limits, not row caps, are the cost lever here.
        There is no cursor for the same reason.


        WHAT IS COUNTED, and it is narrower than the alert list: only
        email-sourced alerts, matched on the entity registry id in `affected_ids`.
        The realtime delay state machine is NOT counted -- it carries no
        `affected_ids`, and its per-train row volume is four orders of magnitude
        larger, so folding it in would swamp every counter. `GET /v1/rail/alerts`
        is the endpoint that returns both pipelines. Because the match is on an
        entity ID and never a station NAME, these counters are immune to the
        two-spellings problem that a name filter has to work around.


        A zero here is a real answer -- it means "no alerts named this entity in
        this range", not "we could not tell" -- which is why the counters are
        integers and not nullable. `total` is not the sum of the impact counters:
        one alert can carry several impact types, and an alert with none carries none.
      operationId: getRailAlertStats
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: group_by
          in: query
          required: true
          description: "`line` groups by the four rail services; `station` groups by all thirty-eight stations."
          schema:
            type: string
            enum: [line, station]
        - $ref: '#/components/parameters/AlertStart'
        - $ref: '#/components/parameters/AlertEnd'
      responses:
        '200':
          description: Alert counters per line or per station.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailAlertStatsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: group_by was missing or not `line`/`station`, or the range was malformed (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/rail/stations:
    get:
      tags: [Rail]
      summary: List rail stations and serving lines
      description: |
        Distinct rail stations observed in the realtime feed, with the lines
        serving each. The whole closed set — 38 stations at the time of writing —
        is returned; this is a directory, not a page, so there is no `limit` and
        no cursor, and no plan truncates it.

        **`station` here is the REALTIME FEED's spelling** (shout-case, e.g.
        `FIVE POINTS STATION`), which is what the `station=` filters on
        `GET /v1/rail/realtime` and the alert lists match against. The entity
        registry spells the same station differently (`Five Points`); those
        filters accept either and expand it, and `GET /v1/rail/alert-stats`
        publishes both spellings side by side as `name` and `feed_name`.
      operationId: listRailStations
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Rail station directory.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RailStationsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: >-
            The station directory is read straight from the database and the
            deployment has none configured, or the read failed
            (`upstream_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus:
    get:
      tags: [Bus]
      summary: List bus route metadata and reliability snapshots
      operationId: listBus
      parameters:
        - $ref: '#/components/parameters/Days'
      responses:
        '200':
          description: Bus route directory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModeRoutesResponse'
        '400':
          description: '`days` is outside 1-365, or is not an integer (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: '`days` was accepted but the upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus/alerts:
    get:
      tags: [Bus]
      summary: List bus alerts
      operationId: listBusAlerts
      parameters:
        - $ref: '#/components/parameters/Format'
        - $ref: '#/components/parameters/AlertStatus'
        - name: route_or_line
          in: query
          schema:
            type: string
          description: Optional bus route filter.
        - $ref: '#/components/parameters/AlertStation'
        - $ref: '#/components/parameters/AlertSource'
        - $ref: '#/components/parameters/AlertStart'
        - $ref: '#/components/parameters/AlertEnd'
        - $ref: '#/components/parameters/AlertAffectedID'
        - $ref: '#/components/parameters/AlertImpactType'
        - $ref: '#/components/parameters/AlertQ'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Matching bus alerts as JSON or SSE.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: 'A filter could not be honoured as written (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'Alert storage is unavailable -- the deployment has no database, or the read failed (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus/realtime:
    get:
      tags: [Bus]
      summary: List realtime bus vehicle positions
      operationId: listBusRealtime
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: query
          schema:
            type: string
          description: Optional bus route filter.
        - $ref: '#/components/parameters/VehicleMinutes'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Latest bus vehicle positions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VehicleRealtimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 1-60 (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream realtime feed could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus/{route_id}/realtime:
    get:
      tags: [Bus]
      summary: List realtime vehicle positions for a bus route
      operationId: getBusRouteRealtime
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/VehicleMinutes'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Latest vehicle positions for the route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VehicleRealtimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 1-60 (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream realtime feed could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus/speed:
    get:
      tags: [Bus]
      summary: Get average bus speed
      operationId: listBusSpeed
      description: |
        Average bus speed per route over trailing windows, system-wide and per
        route.

        **These are speeds WHILE MOVING, and they are biased upward.**
        `moving_vehicles_only` is `true` on every window and is not decoration.
        The collector stores a `0.0` speed reading as NULL, so only about half of
        position rows carry a speed and the missing rows are overwhelmingly
        stationary buses rather than a random sample. Every group reports
        `speed_coverage_pct` (`observations_with_speed` out of
        `observations_total`); label the figure accordingly rather than
        presenting it as an average over all service.

        **Unmeasured is null.** A route with no speed readings returns
        `avg_speed_mph: null`, never `0` — a `0` would read as "this route is
        stopped", which is a different claim entirely.

        **Units.** The source feed reports metres per second; `avg_speed_mps` is
        returned alongside the mph figures so the conversion is auditable rather
        than a claim. `source_resolution_mph` is the granularity the source
        actually carries — do not read more precision into the average than that.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route
          in: query
          description: Optional bus route SHORT name (e.g. `39`, `73`, `A`).
          schema:
            type: string
            maxLength: 64
      responses:
        '200':
          description: Average bus speed per window.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BusSpeedResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`route` is longer than 64 characters (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/bus/service-delivery:
    get:
      tags: [Bus]
      summary: Get bus service delivery (observed versus scheduled buses)
      operationId: listBusServiceDelivery
      description: |
        Buses observed on each route right now versus buses the schedule puts
        out.

        **This is service delivery, not the cancellation-based reliability
        published elsewhere, and the two answer different questions.** A route
        can have zero cancellations and still be running four buses where eight
        should be out.

        **"Scheduled to be out" counts BUSES, not trips.** `scheduled_buses` is
        the number of distinct GTFS `block_id`s — one vehicle's run of work —
        with a trip on this route covering the current instant. The exact
        definition travels in the `scheduled_definition` field of every response.
        The last trip of a block has no end bound in the data and is excluded
        once it departs (`open_ended_trips_excluded`); that UNDERSTATES the
        scheduled count and so errs toward "well served", never toward a
        manufactured shortfall.

        **The difference is NOT "missing buses".** A vehicle that is not
        reporting a position is not a vehicle that is not running: GPS dropouts,
        radio gaps and buses between assignments are indistinguishable in this
        feed from a bus that never left the garage. So:

        - a route with no position reports gets `observed_buses: null` and
          `status: "not_reporting"` — **never `0`**;
        - below `min_route_poll_coverage_pct` the counts are still shown but
          `service_delivery_pct` is `null` (`status: "low_coverage"`), so a
          reporting gap cannot be read as a service gap;
        - if the feed produced fewer than `min_feed_polls` polls, nothing is
          measured and every route reports `feed_unavailable`.

        Do not label the gap between the two counts as missing buses. The
        `attribution_note` field says so in every response.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route
          in: query
          description: |
            Optional bus route SHORT name (e.g. `39`, `73`). Filters the `routes`
            list only; `system` and the coverage denominators always describe the
            whole bus network.
          schema:
            type: string
            maxLength: 64
        - name: minutes
          in: query
          description: Trailing window in minutes. Polls arrive every 1-3 minutes, so 15 minutes is roughly 6-14 polls.
          schema:
            type: integer
            minimum: 5
            maximum: 180
            default: 15
      responses:
        '200':
          description: Observed versus scheduled buses per route.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BusServiceDeliveryResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 5-180, or `route` is longer than 64 characters (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/bus/{route_id}/reliability:
    get:
      tags: [Bus]
      summary: Get bus route reliability
      operationId: getBusReliability
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          schema:
            type: string
        - name: period
          in: query
          schema:
            type: string
            enum: [today, 7d, 30d, 90d]
            default: 30d
        - name: start
          in: query
          description: Inclusive start date (YYYY-MM-DD, America/New_York). When set, overrides period; defaults to 7 days before end.
          schema: { type: string, format: date }
        - name: end
          in: query
          description: Inclusive end date (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: breakdown
          in: query
          schema:
            type: string
            enum: [day, week, period]
      responses:
        '200':
          description: Bus reliability rollup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RouteReliabilityResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: >-
            `start` or `end` is not YYYY-MM-DD, or `breakdown` / `period` was
            given a value outside its enum (`invalid_request`). An unrecognized
            value is rejected rather than ignored: silently falling back to the
            daily arm would answer a different question than the one asked and
            label the answer `"breakdown": "day"`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/bus/{route_id}/trips/today:
    get:
      deprecated: true
      tags: [Bus]
      summary: Get a bus route's trips for a service day
      operationId: getBusTripsToday
      description: |
        **Deprecated for new integrations.** This cancellation-focused legacy
        enumeration remains supported for compatibility. Use
        `/v1/bus/routes/{route_id}/runs?service_date=YYYY-MM-DD` for canonical
        scheduled-run state and status summaries, matched vehicle, and delivery
        assessment. For exact trip or stop alert associations, request
        `/v1/bus/routes/{route_id}/runs/{trip_id}?service_date=YYYY-MM-DD`; for
        route-context notices, request
        `/v1/bus/routes/{route_id}/alerts?service_date=YYYY-MM-DD`.

        Every trip scheduled on one bus route for a single service day, grouped by
        direction, with each trip's cancellation status and a summary per
        direction. This is the data behind the site's per-route "today" view.

        **Cancellation status is a match, not a flag.** A trip is cancelled if a
        GTFS-RT cancelled trip id names it, **or** if a parsed MARTA email
        service alert matches its scheduled departure. The two sources are OR'd,
        never added, so a trip cancelled in both counts once. An email alert
        whose origin resolves through the direction cache matches within
        ±5 minutes and is flagged `is_estimated_cancellation` with the actual
        time difference; one that does not resolve requires an exact
        departure-time match.

        **The undercount is visible.** Email cancellations that matched no
        scheduled trip are listed in `unmatched_cancellations`, and
        `total_cancelled` undercounts by exactly that many. The list travels so
        the shortfall is disclosed rather than silent.

        **Truncation never changes the answer.** The per-direction `trips` array
        is a record enumeration and IS capped by `limit`, which is capped in turn
        by your plan's max query limit, with `trip_count` / `trips_returned` /
        `trips_truncated` disclosed per direction. Every figure above it —
        `total_scheduled`, `total_cancelled`, each direction's summary,
        `cancelled_trips` and `unmatched_cancellations` — is computed over **all**
        trips and is unaffected by the cap, so a free key still gets the correct
        answer to "how many trips today, and how many were cancelled".

        `date` defaults to today in America/New_York, which is the service day
        MARTA operates on.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          description: Bus route short name, e.g. `39`.
          schema:
            type: string
            maxLength: 64
        - name: date
          in: query
          description: Service day to report (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: limit
          in: query
          description: |
            Caps the per-direction `trips` array, and is capped in turn by the
            plan's max query limit. Truncation is always disclosed by
            `trips_truncated` alongside the full `trip_count`. Summary figures are
            never affected.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 500
      responses:
        '200':
          description: The route's scheduled trips for the requested service day.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TripsTodayResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`date` is not YYYY-MM-DD (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No trips are scheduled for this route on that service day (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/bus/routes/{route_id}/runs:
    get:
      tags: [Bus]
      summary: List canonical scheduled runs for a bus route
      operationId: listBusRouteRuns
      description: >-
        A paginated, evidence-backed scheduled-run view for one explicit service
        day. A missing observation remains null or has an explicit coverage
        reason; it is never omitted or coerced to a successful completion.
      security:
        - bearerApiKey: [read:routes]
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, maxLength: 64 }
        - $ref: '#/components/parameters/ServiceDate'
        - $ref: '#/components/parameters/CanonicalCursor'
        - $ref: '#/components/parameters/CanonicalLimit'
      responses:
        '200':
          description: Canonical route runs and page-level delivery evidence.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalRouteRunsResponse' }
        '400':
          description: Invalid route, service_date, cursor, or limit (`invalid_request`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Route or schedule coverage was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/bus/routes/{route_id}/runs/{trip_id}:
    get:
      tags: [Bus]
      summary: Get one canonical scheduled run and its stop visits
      operationId: getBusRouteRun
      description: >-
        One scheduled run with ordered visit axes, timing evidence, matched
        vehicle, delivery assessment, and scoped alert context.
      security:
        - bearerApiKey: [read:routes]
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, maxLength: 64 }
        - name: trip_id
          in: path
          required: true
          schema: { type: string, maxLength: 128 }
        - $ref: '#/components/parameters/ServiceDate'
      responses:
        '200':
          description: Canonical scheduled run.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalRouteRunResponse' }
        '400':
          description: Invalid route, trip, or service_date (`invalid_request`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The route, run, or schedule coverage was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/bus/vehicles/{vehicle_id}/runs:
    get:
      tags: [Bus]
      summary: List directly evidenced scheduled runs for a vehicle
      operationId: listBusVehicleRuns
      description: >-
        Retained trip links for one vehicle on one explicit service day. Alert
        associations are exact trip or stop matches only; route-context notices
        are not presented as vehicle-specific claims.
      security:
        - bearerApiKey: [read:routes]
      parameters:
        - name: vehicle_id
          in: path
          required: true
          schema: { type: string, maxLength: 128 }
        - $ref: '#/components/parameters/ServiceDate'
      responses:
        '200':
          description: Vehicle evidence and its directly linked scheduled runs.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalVehicleRunsResponse' }
        '400':
          description: Invalid vehicle_id or service_date (`invalid_request`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The vehicle or schedule coverage was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/bus/routes/{route_id}/stops/{stop_id}/visits:
    get:
      tags: [Stops]
      summary: List canonical scheduled visits at a route stop
      operationId: listBusRouteStopVisits
      description: >-
        A paginated physical-stop view with run links, visit axes, timing
        evidence, and exact alert matches for one explicit service day.
      security:
        - bearerApiKey: [read:routes]
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, maxLength: 64 }
        - name: stop_id
          in: path
          required: true
          schema: { type: string, maxLength: 128 }
        - $ref: '#/components/parameters/ServiceDate'
        - $ref: '#/components/parameters/CanonicalCursor'
        - $ref: '#/components/parameters/CanonicalLimit'
      responses:
        '200':
          description: Canonical route-stop visits.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalStopVisitsResponse' }
        '400':
          description: Invalid route, stop, service_date, cursor, or limit (`invalid_request`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The route, stop, or schedule coverage was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/bus/routes/{route_id}/map-snapshots:
    get:
      tags: [Bus]
      summary: Get a retained quarter-hour route map snapshot
      operationId: getBusRouteMapSnapshot
      description: >-
        A stored route shape and observation frame. It never interpolates a
        vehicle path or replaces a missing frame with current data; empty or
        partial coverage is stated explicitly.
      security:
        - bearerApiKey: [read:routes]
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, maxLength: 64 }
        - name: at
          in: query
          required: true
          description: >-
            Offset-bearing America/New_York timestamp exactly on a 15-minute
            boundary. Only `-04:00` and `-05:00` offsets are accepted.
          schema:
            type: string
            format: date-time
            pattern: '^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):(?:00|15|30|45):00-0[45]:00$'
      responses:
        '200':
          description: One retained map frame and its actual availability range.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GeoJSONMapSnapshot' }
        '400':
          description: '`at` is not an offset-bearing Eastern quarter-hour timestamp (`invalid_request`).'
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The route was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/bus/routes/{route_id}/alerts:
    get:
      tags: [Alerts]
      summary: List scoped canonical alert contexts for a bus route
      operationId: listBusRouteAlerts
      description: >-
        Route alert context for one explicit service day. Route-wide notices stay
        route-scoped rather than becoming a false trip or vehicle match.
      security:
        - bearerApiKey: [read:routes, read:alerts]
      parameters:
        - name: route_id
          in: path
          required: true
          schema: { type: string, maxLength: 64 }
        - $ref: '#/components/parameters/ServiceDate'
      responses:
        '200':
          description: Retained route alert contexts and exact trip/stop references.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalRouteAlertsResponse' }
        '400':
          description: Invalid route_id or service_date (`invalid_request`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The route or schedule coverage was not found (`not_found`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamUnavailable' }
  /v1/streetcar:
    get:
      tags: [Streetcar]
      summary: List streetcar route metadata
      operationId: listStreetcar
      parameters:
        - $ref: '#/components/parameters/Days'
      responses:
        '200':
          description: Streetcar route directory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModeRoutesResponse'
        '400':
          description: '`days` is outside 1-365, or is not an integer (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: '`days` was accepted but the upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/streetcar/alerts:
    get:
      tags: [Streetcar]
      summary: List streetcar alerts
      operationId: listStreetcarAlerts
      parameters:
        - $ref: '#/components/parameters/Format'
        - $ref: '#/components/parameters/AlertStatus'
        - name: route_or_line
          in: query
          schema:
            type: string
            default: ATLSC
          description: Optional streetcar route filter.
        - $ref: '#/components/parameters/AlertStation'
        - $ref: '#/components/parameters/AlertSource'
        - $ref: '#/components/parameters/AlertStart'
        - $ref: '#/components/parameters/AlertEnd'
        - $ref: '#/components/parameters/AlertAffectedID'
        - $ref: '#/components/parameters/AlertImpactType'
        - $ref: '#/components/parameters/AlertQ'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Matching streetcar alerts as JSON or SSE.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertsResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: 'A filter could not be honoured as written (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'Alert storage is unavailable -- the deployment has no database, or the read failed (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/streetcar/realtime:
    get:
      tags: [Streetcar]
      summary: List realtime streetcar vehicle positions
      operationId: listStreetcarRealtime
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: query
          schema:
            type: string
            default: ATLSC
        - $ref: '#/components/parameters/VehicleMinutes'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Latest streetcar vehicle positions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VehicleRealtimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 1-60 (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream realtime feed could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/streetcar/{route_id}/realtime:
    get:
      tags: [Streetcar]
      summary: List realtime vehicle positions for a streetcar route
      operationId: getStreetcarRouteRealtime
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/VehicleMinutes'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Latest vehicle positions for the streetcar route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VehicleRealtimeResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`minutes` is outside 1-60 (`invalid_request`), or the cursor is unreadable (`invalid_cursor`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream realtime feed could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/streetcar/{route_id}/reliability:
    get:
      tags: [Streetcar]
      summary: Get streetcar reliability
      operationId: getStreetcarReliability
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          schema:
            type: string
            default: ATLSC
        - name: period
          in: query
          schema:
            type: string
            enum: [today, 7d, 30d, 90d]
            default: 30d
        - name: start
          in: query
          description: Inclusive start date (YYYY-MM-DD, America/New_York). When set, overrides period; defaults to 7 days before end.
          schema: { type: string, format: date }
        - name: end
          in: query
          description: Inclusive end date (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: breakdown
          in: query
          schema:
            type: string
            enum: [day, week, period]
      responses:
        '200':
          description: Streetcar reliability rollup. Empty record sets are valid when no reliability rows are available for the requested route/window.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RouteReliabilityResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: >-
            `start` or `end` is not YYYY-MM-DD, or `breakdown` / `period` was
            given a value outside its enum (`invalid_request`). An unrecognized
            value is rejected rather than ignored: silently falling back to the
            daily arm would answer a different question than the one asked and
            label the answer `"breakdown": "day"`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: 'The upstream reliability service could not answer (`upstream_unavailable`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/streetcar/{route_id}/trips/today:
    get:
      tags: [Streetcar]
      summary: Get a streetcar route's trips for a service day
      operationId: getStreetcarTripsToday
      description: |
        Every trip scheduled on one streetcar route for a single service day, grouped by
        direction, with each trip's cancellation status and a summary per
        direction. This is the data behind the site's per-route "today" view.

        **MARTA publishes no cancellation signal for streetcar, so every
        cancellation-derived field here is `null`.** MARTA's cancellation sources are bus-scoped: the email alert feed publishes `bus_service` alerts keyed by route number, and no streetcar trip has ever appeared in `gtfs_rt_canceled_trips`. `total_cancelled`,
        `reliability_pct`, each direction's `cancelled`/`reliability_pct`, and
        **each trip's `is_cancelled`** are `null` — not `0`, not `false`, not
        `100.0`. The upstream computes `100.0%` and `is_cancelled: false` for
        this mode, and both are artifacts of a feed that has never carried a
        streetcar cancellation. Republishing them would assert, trip by trip, that
        each vehicle ran; that cannot be known from the absence of a feed.
        `cancellation_signal.available` is `false` and says why in the payload.

        **The schedule side is real.** `total_scheduled`, the directions,
        headsigns and departure times all come from the GTFS feed vintage
        effective for the requested date, and are unaffected.

        **Truncation never changes the answer.** The per-direction `trips` array
        is a record enumeration and IS capped by `limit`, which is capped in turn
        by your plan's max query limit, with `trip_count` / `trips_returned` /
        `trips_truncated` disclosed per direction. Every figure above it —
        `total_scheduled`, `total_cancelled`, each direction's summary,
        `cancelled_trips` and `unmatched_cancellations` — is computed over **all**
        trips and is unaffected by the cap, so a free key still gets the correct
        answer to "how many trips today, and how many were cancelled".

        `date` defaults to today in America/New_York, which is the service day
        MARTA operates on.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: route_id
          in: path
          required: true
          description: 'Streetcar route short name. MARTA operates one: `ATLSC`.'
          schema:
            type: string
            maxLength: 64
            default: ATLSC
        - name: date
          in: query
          description: Service day to report (YYYY-MM-DD, America/New_York). Defaults to today.
          schema: { type: string, format: date }
        - name: limit
          in: query
          description: |
            Caps the per-direction `trips` array, and is capped in turn by the
            plan's max query limit. Truncation is always disclosed by
            `trips_truncated` alongside the full `trip_count`. Summary figures are
            never affected.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 500
      responses:
        '200':
          description: The route's scheduled trips for the requested service day.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TripsTodayResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: '`date` is not YYYY-MM-DD (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No trips are scheduled for this route on that service day (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/system/reliability:
    get:
      tags: [System]
      summary: Get system-wide reliability
      operationId: getSystemReliability
      description: |
        Scheduled-versus-cancelled reliability for the network as a whole, broken
        down either by MARTA service period (`breakdown=period`, the default) or
        by route and service period (`breakdown=route` — the data behind the
        route-failure heatmap).

        **BUS ONLY, and that is a property of the data rather than of the query.**
        The nightly aggregation behind this figure is written for GTFS
        `route_type` 3 only, and the live arm that covers today reads a bus-only
        view. Rail and streetcar are not missing rows: MARTA publishes no
        cancellation signal for them, so a scheduled-versus-cancelled figure
        cannot be computed for them at all. Rail service quality is published
        separately as on-time performance at `GET /v1/rail/on-time`. **That is a
        different metric and must never be silently compared to this one.** The
        `mode` field and the `scope_note` in every response say so, because a
        consumer that reads this as "MARTA system reliability" is wrong by
        roughly the whole rail network.

        **This aggregate is NOT capped by your plan's max query limit.** That cap
        exists to bound page size on record feeds; a system aggregate is a
        closed, network-sized set (six service periods; on the order of eighty
        bus routes) that the producer computes in full either way. Returning 20
        of 81 routes would not be a smaller true answer, it would be a different
        and false one — "system reliability over a fifth of the network" is not
        system reliability. Cost is bounded by rate limits, which is the right
        lever. `route_count`, `routes_returned` and `routes_truncated` are still
        disclosed on the `breakdown=route` arm; `routes_truncated` becomes true
        only if an absolute response ceiling (500 routes) is reached, which the
        current network is nowhere near.

        **Unmeasured is null.** `reliability_pct` is `null` — never `0` and never
        `100` — whenever `total_scheduled` is `0` for that bucket. An absent
        period means "not known", never "nothing ran". Whether a period with no
        data is omitted or present with zeros depends on the arm: the historical
        arm omits it, while the live arm covering today emits all six periods
        with `total_scheduled: 0`. `total_scheduled: 0` means "no scheduled trips
        were found", which for an unrecognized route id in `routes` is
        indistinguishable from a real route with nothing scheduled.

        **What the range actually covers.** `start_date`/`end_date` are the
        requested range as resolved, not necessarily the range the figures rest
        on. The historical arm is clamped to `coverage_start_date` at the low end
        and to yesterday at the high end; when the range includes today, today's
        live counts are added on top before each percentage is computed. Compare
        `start_date` with `coverage_start_date` before presenting a long window.

        **There is no `breakdown=day`.** No system-wide daily aggregate exists
        upstream, and this endpoint will not synthesise one. Daily series are
        available per route at `GET /v1/{mode}/{route_id}/reliability?breakdown=day`.
      parameters:
        - $ref: '#/components/parameters/Format'
        - name: breakdown
          in: query
          description: |
            `period` (default) aggregates every bus route into one row per MARTA
            service period. `route` returns one entry per bus route, each with
            its own period rows. `day` is not offered — see the description.
          schema:
            type: string
            enum: [period, route]
            default: period
        - $ref: '#/components/parameters/Days'
        - name: start
          in: query
          description: Inclusive start date (YYYY-MM-DD, America/New_York). When set with `end`, overrides `days`.
          schema: { type: string, format: date }
        - name: end
          in: query
          description: Inclusive end date (YYYY-MM-DD, America/New_York).
          schema: { type: string, format: date }
        - name: routes
          in: query
          description: |
            `breakdown=route` only. Comma-separated route short names to restrict
            the answer to (e.g. `89,84,42`). Omit it to cover every bus route in
            the current GTFS feed, which is the point of this endpoint. An
            unrecognized id is not an error and is not distinguishable from a
            route with no data — both return an empty `periods` list.
          schema:
            type: string
            maxLength: 4096
      responses:
        '200':
          description: System-wide bus reliability for the requested breakdown.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemReliabilityResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '400':
          description: |
            `breakdown` is not `period` or `route` (`day` is rejected with an
            explanation and a pointer to the per-route endpoint), `days` is
            outside 1-365, `start`/`end` are not YYYY-MM-DD, `routes` is longer
            than 4096 characters, or `routes` names no usable id at all
            (`invalid_request`).

            Note that `routes=` with an empty value is NOT an error: it is
            treated as absent, and the answer covers every bus route. Only a
            non-empty value that yields no ids after splitting -- `routes=,`,
            say -- is rejected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/system/today:
    get:
      tags: [System]
      summary: Get today's system-wide cancellation count
      operationId: getSystemToday
      description: |
        Today's system-wide scheduled and cancelled trip counts — the figure
        behind the site's cancellations banner.

        **BUS ONLY**, for the same reason as `GET /v1/system/reliability`: MARTA
        publishes no cancellation signal for rail or streetcar. `scope_note`
        carries that statement in the payload.

        **Today is not over.** `scheduled_count` is the **whole** service day's
        scheduled trips, while `cancelled_count` is only what has been reported
        so far. `reliability_pct` is therefore an in-progress figure that can
        only fall as the day goes on, and must not be compared with a completed
        day. `date` is the current date in America/New_York, the service day
        MARTA operates on.

        **Unmeasured is null.** `reliability_pct` is `null` — never `100` — when
        `scheduled_count` is `0`. `last_updated` is `null` when neither
        cancellation source has produced anything today; that is "nothing has
        been reported yet", not "nothing has been cancelled".

        The two cancellation sources (GTFS-RT cancelled trips and parsed MARTA
        email service alerts) are matched to real scheduled trips and OR'd, never
        added, so a trip cancelled in both sources counts once.
      parameters:
        - $ref: '#/components/parameters/Format'
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Today's system-wide bus cancellation count.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
            X-RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemTodayResponse'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/history/exports:
    get:
      tags: [History]
      summary: List historical export manifests (Max only)
      operationId: listHistoryExports
      description: |
        The export manifests visible to you: the free canonical daily artifacts
        every Max customer sees (`shared: true`) plus your own on-demand exports.
        Other customers' exports are never listed.

        **Metered as one `api_request`, not as a `bulk_export`.** `bulk_export`
        is charged when an artifact is GENERATED; enumerating the ones that
        already exist is an ordinary read.

        Requires the **Max** plan, like every other bulk-download surface. A Free
        or Regular key is refused with `403 feature_not_in_plan` rather than
        shown an empty list, because an empty list would read as "there are no
        exports".
      parameters:
        - name: dataset
          in: query
          required: false
          schema:
            type: string
            # Must match the values the generator stores; a documented filter
            # that didn't match returned zero rows.
            enum: [reliability, realtime, alerts]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Export manifests.
          headers:
            X-Meter-Unit:
              $ref: '#/components/headers/MeterUnit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryExportsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Bulk downloads require the Max plan (`feature_not_in_plan`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [History]
      summary: Enqueue generation of a bulk export (Max only)
      description: |
        Reserves a 'pending' manifest row for the gtfs-analysis claim loop to
        process asynchronously -- this endpoint NEVER generates inline. Accepts
        either a `days` preset (N inclusive days ending today, America/New_York)
        or an explicit start_date/end_date range. Returns 200 with the existing
        manifest when an unexpired match already exists (the caller's own or
        the shared canonical artifact); 202 when newly enqueued or already
        pending.
      operationId: createHistoryExport
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HistoryExportGenerateRequest'
      responses:
        '200':
          description: A matching unexpired export already exists; no new job was enqueued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryExportGenerateResponse'
        '202':
          description: Export generation enqueued (or already pending).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryExportGenerateResponse'
        '400':
          description: >-
            Invalid dataset, invalid or backwards date range, or a range
            exceeding the dataset's cap (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            Bulk downloads require the Max plan, and so does `format: parquet`
            specifically (`feature_not_in_plan`). A Parquet request from a
            non-Max key is refused, never quietly downgraded to `ndjson.gz`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: >-
            The daily export-generation limit, or the plan rate limit, was
            reached (`plan_limit_exceeded` / `rate_limited`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/history/exports/{id}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [History]
      summary: Get a short-lived signed download URL for an export manifest
      operationId: downloadHistoryExport
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '302':
          description: >-
            Redirect to a short-lived signed R2 URL (five minutes). Treat the
            redirect target as a credential: do not cache, store or share it,
            come back here for a fresh one.
          headers:
            Location:
              description: The signed URL.
              schema:
                type: string
                format: uri
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Bulk downloads require the Max plan (`feature_not_in_plan`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such manifest for this caller, or it is not downloadable --
            pending, failed or expired (`not_found`). A manifest belonging to
            another customer is reported as not found rather than forbidden, so
            the response does not confirm that it exists.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/webhooks/endpoints:
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      operationId: createWebhookEndpoint
      description: |
        Registers a signed delivery target. The response is the ONLY time the
        full `signing_secret` is returned — store it now; every later read
        exposes just `signing_secret_prefix`.

        The number of endpoints is capped by your plan's
        `max_webhook_endpoints` (0 on Free, 5 on Regular, 25 on Max); over the
        cap is `403 webhook_limit_reached`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreate'
      responses:
        '201':
          description: Webhook endpoint created; `signing_secret` is present exactly once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointEnvelope'
        '400':
          description: The URL or the event types could not be accepted as written (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The plan's webhook endpoint limit is already reached (`webhook_limit_reached`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Owner's webhook endpoints.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: The deployment has no database configured (`upstream_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/webhooks/endpoints/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Webhooks]
      summary: Update a webhook endpoint (and optionally rotate its secret)
      operationId: updateWebhookEndpoint
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdate'
      responses:
        '200':
          description: >-
            Updated endpoint. `signing_secret` is present only when
            `rotate_secret` was true, and that is the last time the value is
            readable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointEnvelope'
        '400':
          description: The update could not be applied as written (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No such endpoint for this owner (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [Webhooks]
      summary: Delete (disable) a webhook endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Endpoint disabled.
          content:
            application/json:
              schema:
                type: object
                required: [deleted, id]
                properties:
                  deleted: { type: boolean }
                  id: { type: string, format: uuid }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No such endpoint for this owner (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/events/stream:
    get:
      tags: [Events]
      summary: Persistent SSE event stream
      operationId: streamEvents
      parameters:
        - { name: mode, in: query, schema: { type: string } }
        - { name: route_or_line, in: query, schema: { type: string } }
        - { name: severity, in: query, schema: { type: string } }
      responses:
        '400':
          description: |
            The request was malformed (`invalid_request`).

            Reachable on every operation in this document, not only the ones that
            take filters: `?format=` is validated against its enum before routing,
            so any endpoint answers 400 for a framing it cannot produce.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: An open server-sent event stream (ready + heartbeats).
          content:
            text/event-stream:
              schema:
                $ref: '#/components/schemas/SseStream'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            The plan's concurrent-stream limit is already held
            (`sse_limit_reached`). Free keys have `max_sse_connections: 0`, so
            this endpoint is closed to them entirely.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/webhooks/deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook delivery attempts
      operationId: listWebhookDeliveries
      description: >-
        The delivery audit log for your own endpoints. This is a RECORD LIST, so
        it is cursor-paginated and its page size is bounded by your plan's
        `max_query_limit`.
      parameters:
        - name: endpoint_id
          in: query
          description: Restrict to one endpoint. A value that is not a UUID is a `400 invalid_request`.
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: >-
            Restrict to one delivery state. An unsupported value is a
            `400 invalid_request` whose `details.supported_statuses` lists the
            accepted set.
          schema:
            type: string
            enum: [pending, delivered, retrying, failed]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Webhook delivery audit records.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveriesResponse'
        '400':
          description: '`endpoint_id` is not a UUID, or `status` is not a supported state (`invalid_request`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: The deployment has no database configured (`upstream_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: ATL Transit Live API key
  headers:
    MeterUnit:
      description: >-
        Billable meter unit for the request when successful. Every snapshot read
        on this API bills exactly one `api_request`; the other units are charged
        elsewhere (`sse_connection_minute` per minute of a held
        `GET /v1/events/stream`, `bulk_export` when an export artifact is
        generated). Read this rather than inferring the unit from the path.
      schema:
        $ref: '#/components/schemas/MeterUnit'
    RateLimitPolicy:
      description: Human-readable plan rate-limit policy applied to this endpoint.
      schema:
        type: string
  parameters:
    Mode:
      name: mode
      in: query
      schema:
        $ref: '#/components/schemas/Mode'
    Days:
      name: days
      in: query
      description: Reliability lookback window used for route snapshots.
      schema:
        type: integer
        minimum: 1
        maximum: 365
        default: 30
    VehicleMinutes:
      name: minutes
      in: query
      description: Vehicle position lookback window.
      schema:
        type: integer
        minimum: 1
        maximum: 60
        default: 10
    Limit:
      name: limit
      in: query
      description: >-
        Page size, capped by your plan's `max_query_limit` -- 20 on Free, 100 on
        Regular, 1000 on Max. The cap is silent: a larger value is clamped rather
        than rejected, and `page_info.limit` reports what was actually applied,
        so read it back rather than assuming you got what you asked for. There is
        no `basic` or `standard` plan; those names were superseded by
        Free/Regular/Max. `max_query_limit` is the authority (read it from
        `GET /v1/health`), because it is effective-dated in the database and the
        figures here are only a snapshot of it.
      schema:
        type: integer
        minimum: 1
        # No `maximum`: the real ceiling is the plan's max_query_limit, which is
        # 1000 on Max -- a schema maximum of 500 would have made a valid Max
        # request fail client-side validation for exceeding a bound the server
        # does not enforce.
        default: 100
    Cursor:
      name: cursor
      in: query
      description: Opaque continuation token from `page_info.next_cursor`.
      schema:
        type: string
    ServiceDate:
      name: service_date
      in: query
      required: true
      description: Explicit MARTA service day (`YYYY-MM-DD`, America/New_York).
      schema:
        type: string
        format: date
    CanonicalCursor:
      name: cursor
      in: query
      description: Opaque continuation token from a canonical transit `next_cursor`.
      schema:
        type: string
    CanonicalLimit:
      name: limit
      in: query
      description: Page size for canonical runs or visits (1 through 250).
      schema:
        type: integer
        minimum: 1
        maximum: 250
        default: 100
    RailLine:
      name: line
      in: query
      description: "Rail line filter: RED, GOLD, BLUE, or GREEN. Comma-delimited list accepted (e.g. RED,GOLD)."
      schema:
        type: string
    AlertStatus:
      name: status
      in: query
      description: >-
        `active` is the rolling 72-hour window, `resolved` is everything older, and
        `all` is the union of the two IN ONE REQUEST. Before `all` existed a client
        that wanted both had to fire two requests and merge them itself, which also
        made the page counts and the cursor meaningless.
      schema:
        type: string
        enum: [active, resolved, all]
        default: active
    AlertStation:
      name: station
      in: query
      description: >-
        Optional station filter for alerts that include a station. Comma-delimited
        list accepted. THE VALUE IS EXPANDED TO EVERY SPELLING THE STATION IS
        STORED UNDER before it reaches the query: realtime rows carry the feed's
        shout-case ("FIVE POINTS STATION") while email rows carry the entity name
        ("Five Points"), and an exact match on one alone silently drops the other
        pipeline's rows entirely. `filters.station` echoes what you asked for and
        `filters.station_expanded` lists what was actually queried. A value that
        names no known station (a bus alert's street intersection, or a partial
        name that plausibly names two stations) is passed through untouched.
        Only rail realtime rows carry a station at all, so on a cross-mode request
        this filter structurally excludes bus and streetcar.
      schema:
        type: string
    AlertStart:
      name: start
      in: query
      description: >-
        Inclusive first day of the range, `YYYY-MM-DD`, interpreted in
        America/New_York -- a bare date only names an instant once you say whose
        midnight you mean. May be given without `end`. Bounds `created_at` on
        email-sourced alerts and `COALESCE(published_at, created_at)` on
        realtime-sourced ones, which is the instant each became visible.
      schema:
        type: string
        format: date
    AlertEnd:
      name: end
      in: query
      description: >-
        Inclusive last day of the range, `YYYY-MM-DD`, America/New_York. The whole
        Eastern day is included. May be given without `start`.
      schema:
        type: string
        format: date
    AlertQ:
      name: q
      in: query
      description: >-
        Case-insensitive substring search. Matches the microblog message on
        email-sourced alerts and the title or body on realtime-sourced ones.
      schema:
        type: string
    AlertAffectedID:
      name: affected_id
      in: query
      description: >-
        Filter to alerts naming this entity registry id (`RAIL_SERVICE_RED`,
        `RAIL_STATION_FIVEPOINTS`, `BUS_ROUTE_39`, `STREETCAR_LINE_DOWNTOWN`). This
        is the only stable identifier an alert carries, and unlike a station NAME it
        is written identically by both pipelines. Realtime rows carry no
        `affected_ids` -- they name their subject with (mode, route_or_line) -- so a
        LINE or ROUTE id is translated onto those columns, while a rail STATION id
        cannot be translated at all and DROPS the realtime arm from the response
        rather than attributing rows to an entity they are not about.
      schema:
        type: string
    AlertImpactType:
      name: impact_type
      in: query
      description: >-
        Filter to alerts carrying this entry in `impact.impact_types` (`delay`,
        `cancellation`, `mechanical`, `service_change`, ...). The vocabulary is
        written by the alert generator, so this is free-form on purpose. Realtime
        alerts are delay-only by construction, so any value other than `delay`
        excludes that arm entirely.
      schema:
        type: string
    AlertMode:
      name: mode
      in: query
      description: >-
        Optional mode filter for the cross-mode list. Omit it to receive every mode
        in one page -- see `cross_mode_note` and `mode_counts` in the response.
      schema:
        type: string
        enum: [rail, bus, streetcar, facility]
    AlertSource:
      name: source
      in: query
      description: "Comma-delimited alert sources: `email` (MARTA's official emailed alerts) and/or `marta_rt` (realtime-delay-derived alerts). Defaults to all sources."
      schema:
        type: string
    Format:
      name: format
      in: query
      description: "Response format: `json` (default) or `sse`. The `Accept` header takes precedence on conflict — `Accept: text/event-stream` forces SSE and `Accept: application/json` forces JSON regardless of this parameter."
      schema:
        type: string
        enum: [json, sse]
        default: json
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Opaque client key; a retried mutation with the same key replays the original response.
      schema: { type: string, maxLength: 255 }
  responses:
    Unauthorized:
      description: Missing, expired, revoked, or unauthorized API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UpstreamUnavailable:
      description: |
        The transit-data upstream this endpoint proxies could not be reached, or
        answered with something unusable (`upstream_unavailable`).

        Every endpoint that reads from gtfs-analysis can return this, and it is
        not an exceptional deployment state — it is what a caller sees whenever
        that upstream is restarting or degraded. Treat it as retryable: the
        request was well-formed and the key was accepted, so re-issuing it later
        is the correct response, unlike a 4xx.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema:
            type: integer
        X-RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    Mode:
      type: string
      enum: [bus, rail, streetcar]
    MeterUnit:
      type: string
      enum: [api_request, sse_connection_minute, sse_event, webhook_event, bulk_export]
    SseStream:
      type: string
      description: "Server-sent event stream returned from the same resource URL when requested with `Accept: text/event-stream` or `format=sse`. On conflict the `Accept` header wins over `format`."
      example: "event: ready\ndata: {\"api_version\":\"v1\",\"mode\":\"rail\"}\n\nevent: rail.realtime.snapshot\ndata: {\"mode\":\"rail\",\"arrivals\":[]}\n\n"
    PlanLimits:
      type: object
      description: >-
        The hard limits a plan enforces. Billing is flat-tier: an over-limit
        request is refused, never billed as overage.
      required:
        - rate_per_5min
        - rate_per_day
        - max_sse_connections
        - max_webhook_endpoints
        - max_query_limit
        - max_api_keys
        - bulk_downloads
        - historical_stop_analytics
      properties:
        rate_per_5min:
          type: integer
          description: Requests allowed in a rolling 5-minute burst window.
        rate_per_day:
          type: integer
          description: Requests allowed per calendar day (America/New_York).
        max_sse_connections:
          type: integer
          description: Concurrent `/v1/events/stream` connections. `0` means SSE is not in the plan.
        max_webhook_endpoints:
          type: integer
          description: Webhook endpoints that may exist at once. `0` means webhooks are not in the plan.
        max_query_limit:
          type: integer
          description: >-
            Ceiling applied to `?limit=`. A larger request is silently clamped to
            this value and the clamped figure is reported back in
            `page_info.limit`. Aggregates are not subject to it — only record
            lists are.
        max_api_keys:
          type: integer
          description: API keys the account may hold at once.
        bulk_downloads:
          type: boolean
          description: Whether history exports are available to this plan.
        historical_stop_analytics:
          type: boolean
          description: Whether bounded aggregate stop prediction history is available. Initially Max only and independent of bulk downloads.
    StopFreshness:
      type: object
      required: [source_age_seconds, stale_after, status]
      properties:
        source_age_seconds: { type: integer, nullable: true, minimum: 0 }
        stale_after: { type: string, format: date-time, nullable: true }
        status: { type: string, enum: [fresh, stale, unknown] }
    StopCurrentPrediction:
      type: object
      description: Current projection row. This is not a retained raw observation.
      required: [direction_id, trip_id, service_date, scheduled_at, marta_predicted_arrival_at, marta_predicted_departure_at, marta_schedule_deviation_seconds, observed_at, freshness, status_class]
      properties:
        direction_id: { type: integer, nullable: true }
        trip_id: { type: string }
        service_date: { type: string, format: date, nullable: true }
        scheduled_at: { type: string, format: date-time, nullable: true }
        marta_predicted_arrival_at: { type: string, format: date-time, nullable: true }
        marta_predicted_departure_at: { type: string, format: date-time, nullable: true }
        marta_schedule_deviation_seconds: { type: integer, nullable: true }
        atl_provider: { type: string, nullable: true }
        atl_predicted_arrival_at: { type: string, format: date-time, nullable: true }
        atl_predicted_departure_at: { type: string, format: date-time, nullable: true }
        atl_model_version: { type: string, nullable: true }
        atl_forecast_horizon_seconds: { type: integer, nullable: true }
        atl_accuracy_seconds:
          type: number
          nullable: true
          description: Always null on current rows; measured accuracy belongs to sufficiently sized aggregate cohorts.
        atl_accuracy_status: { type: string, enum: [insufficient_actuals] }
        observed_at: { type: string, format: date-time, nullable: true }
        freshness: { $ref: '#/components/schemas/StopFreshness' }
        status_class: { type: string, enum: [early, on_time, late, extra_late, unknown] }
    RouteStopsResponse:
      type: object
      required: [type, properties, count, features]
      properties:
        type: { type: string, enum: [FeatureCollection] }
        properties:
          type: object
          required: [route_id]
          properties: { route_id: { type: string } }
        count: { type: integer }
        features:
          type: array
          items:
            type: object
            required: [type, geometry, properties]
            properties:
              type: { type: string, enum: [Feature] }
              geometry:
                type: object
                required: [type, coordinates]
                properties:
                  type: { type: string, enum: [Point] }
                  coordinates:
                    type: array
                    minItems: 2
                    maxItems: 2
                    items: { type: number }
              properties:
                type: object
                required: [stop_id, stop_code, stop_name, stop_desc, wheelchair_boarding, stop_sequence, current_predictions]
                properties:
                  stop_id: { type: string }
                  stop_code: { type: string, nullable: true }
                  stop_name: { type: string }
                  stop_desc: { type: string, nullable: true }
                  wheelchair_boarding: { type: integer }
                  stop_sequence: { type: integer }
                  current_predictions:
                    type: array
                    items: { $ref: '#/components/schemas/StopCurrentPrediction' }
    StopPredictionsResponse:
      type: object
      required: [route_id, stop_id, count, page_info, predictions]
      properties:
        route_id: { type: string }
        stop_id: { type: string }
        count: { type: integer }
        page_info: { $ref: '#/components/schemas/PageInfo' }
        predictions:
          type: array
          items: { $ref: '#/components/schemas/StopCurrentPrediction' }
    StopCoverage:
      type: object
      required: [scheduled, marta, atl]
      properties:
        scheduled: { type: number, format: double, minimum: 0, maximum: 1, nullable: true }
        marta: { type: number, format: double, minimum: 0, maximum: 1, nullable: true }
        atl: { type: number, format: double, minimum: 0, maximum: 1, nullable: true }
    StopPredictionDailyAggregate:
      type: object
      description: Daily aggregate only; contains no source, database, trip, vehicle, R2, or raw-observation identifier.
      required: [service_day, direction_id, sample_count, unknown_sample_count, scheduled_sample_count, marta_prediction_count, atl_prediction_count, independent_actual_count, coverage, marta_schedule_deviation_median_seconds, marta_schedule_deviation_p50_seconds, marta_schedule_deviation_p90_seconds, status_shares, average_prediction_freshness_seconds, prediction_freshness_p90_seconds, marta_absolute_error_p50_seconds, marta_absolute_error_p90_seconds, atl_accuracy_seconds, atl_absolute_error_p50_seconds, atl_absolute_error_p90_seconds, atl_accuracy_sample_count, atl_accuracy_status, first_observed_at, last_observed_at]
      properties:
        service_day: { type: string, format: date }
        direction_id: { type: integer, nullable: true }
        sample_count: { type: integer, format: int64 }
        unknown_sample_count: { type: integer, format: int64 }
        scheduled_sample_count: { type: integer, format: int64 }
        marta_prediction_count: { type: integer, format: int64 }
        atl_prediction_count: { type: integer, format: int64 }
        independent_actual_count: { type: integer, format: int64 }
        coverage: { $ref: '#/components/schemas/StopCoverage' }
        marta_schedule_deviation_median_seconds: { type: number, nullable: true }
        marta_schedule_deviation_p50_seconds: { type: number, nullable: true }
        marta_schedule_deviation_p90_seconds: { type: number, nullable: true }
        status_shares: { type: object, additionalProperties: { type: number, nullable: true } }
        average_prediction_freshness_seconds: { type: number, nullable: true }
        prediction_freshness_p90_seconds: { type: number, nullable: true }
        marta_absolute_error_p50_seconds: { type: number, nullable: true }
        marta_absolute_error_p90_seconds: { type: number, nullable: true }
        atl_accuracy_seconds: { type: number, nullable: true }
        atl_absolute_error_p50_seconds: { type: number, nullable: true }
        atl_absolute_error_p90_seconds: { type: number, nullable: true }
        atl_accuracy_sample_count: { type: integer, format: int64 }
        atl_accuracy_status: { type: string, enum: [measured, insufficient_actuals] }
        first_observed_at: { type: string, format: date-time }
        last_observed_at: { type: string, format: date-time }
    StopPredictionHistoryResponse:
      type: object
      description: Bounded aggregate response; never a raw event stream.
      required: [route_id, stop_id, period, timezone, start_date, end_date, metric, thresholds_seconds, aggregate_only, period_aggregate, daily]
      properties:
        route_id: { type: string }
        stop_id: { type: string }
        period: { type: string, enum: [7d, 30d, 90d] }
        timezone: { type: string, enum: [America/New_York] }
        start_date: { type: string, format: date }
        end_date: { type: string, format: date }
        metric: { type: string, enum: [prediction_adherence] }
        thresholds_seconds: { type: object, additionalProperties: { type: integer } }
        aggregate_only: { type: boolean, enum: [true] }
        period_aggregate:
          type: object
          additionalProperties: true
          description: Weighted sample/coverage totals and earliest/latest aggregate availability; no raw identifiers.
        daily:
          type: array
          items: { $ref: '#/components/schemas/StopPredictionDailyAggregate' }
    BillingPlan:
      type: object
      description: One plan in the public catalogue.
      # Spelled out because a generated example would fill the nullable prices
      # with `0`, which is precisely the reading this endpoint exists to prevent:
      # the free plan has NO price, and 0 would be a price.
      # Singular `example`, because a Schema Object in 3.0 carries one. The free
      # plan is the one that has to be shown: it is the case where a generated
      # example would invent `0` prices. The paid shape is carried as a second
      # named example on the GET /v1/billing/plans response, where 3.0 does
      # allow more than one -- nothing was dropped in the 3.1 -> 3.0 move.
      example:
        plan_slug: free
        currency: usd
        amounts:
          month: null
          year: null
        limits:
          rate_per_5min: 20
          rate_per_day: 300
          max_sse_connections: 0
          max_webhook_endpoints: 0
          max_query_limit: 20
          max_api_keys: 1
          bulk_downloads: false
          historical_stop_analytics: false
      required: [plan_slug, currency, amounts, limits]
      properties:
        plan_slug:
          type: string
          enum: [free, regular, max]
        currency:
          type: string
          description: ISO 4217 code, lower case (e.g. `usd`).
        amounts:
          type: object
          description: >-
            Price in minor units (US cents) per billing interval. Annual is
            discounted against twelve months.
          required: [month, year]
          properties:
            month:
              type: integer
              nullable: true
              description: >-
                `null` when no active monthly price is provisioned — the `free`
                plan, or a catalogue that could not be read. Never `0`; a null
                price is unknown, not free.
            year:
              type: integer
              nullable: true
              description: >-
                `null` when no active annual price is provisioned. Never `0`; a
                null price is unknown, not free.
        limits:
          $ref: '#/components/schemas/PlanLimits'
    PageInfo:
      type: object
      required: [limit, next_cursor, total_available]
      properties:
        limit:
          type: integer
          description: Page size after plan caps are applied.
        next_cursor:
          type: string
          nullable: true
          description: Opaque token for the next page, or null at the end of the result set.
        total_available:
          type: integer
          description: Records available for the current filters and time window.
    CanonicalTransitEnvelope:
      type: object
      additionalProperties: false
      description: >-
        Common evidence envelope for canonical transit reads. `as_of`,
        `service_date`, `schedule_vintage`, and coverage are always explicit so
        a caller never confuses unavailable evidence with a successful result.
      required: [as_of, service_date, schedule_vintage, coverage, route]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
    CanonicalCoverage:
      type: object
      additionalProperties: false
      description: >-
        Availability and no-evidence reason for a canonical fact. A no-evidence
        fact is null or an explicit coverage reason, never omitted and never
        coerced to success.
      required: [status, reason]
      properties:
        status:
          type: string
          enum: [available, complete, partial, unavailable]
        reason:
          type: string
          nullable: true
    CanonicalRoute:
      type: object
      additionalProperties: false
      required: [route_id, short_name, long_name]
      properties:
        route_id: { type: string }
        short_name: { type: string, nullable: true }
        long_name: { type: string, nullable: true }
    TripIdentity:
      type: object
      additionalProperties: false
      required: [trip_id, trip_headsign, direction_id, block_id]
      properties:
        trip_id: { type: string, nullable: true }
        trip_headsign: { type: string, nullable: true }
        direction_id: { type: integer, nullable: true }
        block_id: { type: string, nullable: true }
    VehicleRunSummary:
      type: object
      additionalProperties: false
      required: [vehicle_id, first_observed_at, last_observed_at]
      properties:
        vehicle_id: { type: string, nullable: true }
        first_observed_at: { type: string, format: date-time, nullable: true }
        last_observed_at: { type: string, format: date-time, nullable: true }
    CursorPage:
      type: object
      additionalProperties: false
      description: >-
        Cursor page metadata. `next_cursor` is null, rather than absent, when
        the emitted page is final.
      required: [next_cursor]
      properties:
        next_cursor: { type: string, nullable: true }
    RunState:
      type: string
      description: >-
        Canonical scheduled-run state. `scheduled` means a future or
        schedule-only run, never a delay or completion claim.
        `assumed_completed` requires recorded start or movement evidence, no
        cancellation or contradictory alert, and later tracking loss before the
        final stop; it is distinct from evidence-confirmed `completed`.
        `ghost` is an affirmative no-movement-after-deadline classification, not
        an unmatched vehicle. `tracking_gap` identifies an unresolved loss of
        evidence. `status_unavailable` means the available evidence cannot
        support a run-state conclusion.
      enum:
        - currently_dispatched
        - completed
        - assumed_completed
        - scheduled
        - scheduled_run_ended
        - cancelled
        - ghost
        - tracking_gap
        - status_unavailable
    DeliveryAssessment:
      type: object
      additionalProperties: false
      description: >-
        Evidence-backed inclusion in confirmed and estimated service-delivery
        measures. It does not turn lack of tracking into a delivered run.
      required: [classification, included_in_confirmed_delivery, included_in_estimated_delivery, basis]
      properties:
        classification: { type: string, nullable: true }
        included_in_confirmed_delivery: { type: boolean, nullable: true }
        included_in_estimated_delivery: { type: boolean, nullable: true }
        basis:
          type: object
          additionalProperties: false
          required: [last_movement_at, first_incomplete_stop_sequence, tripupdate_coverage_status, has_exact_cancellation]
          properties:
            last_movement_at: { type: string, format: date-time, nullable: true }
            first_incomplete_stop_sequence: { type: integer, nullable: true }
            tripupdate_coverage_status: { type: string, nullable: true }
            has_exact_cancellation: { type: boolean, nullable: true }
    DeliveryMeasure:
      type: object
      additionalProperties: false
      required: [numerator, denominator, pct]
      properties:
        numerator: { type: integer, nullable: true }
        denominator: { type: integer, nullable: true }
        pct: { type: number, nullable: true }
    DeliverySummary:
      type: object
      additionalProperties: false
      required: [run_state_counts, confirmed_delivery, estimated_delivery]
      properties:
        run_state_counts: { $ref: '#/components/schemas/RunStateCounts' }
        confirmed_delivery: { $ref: '#/components/schemas/DeliveryMeasure' }
        estimated_delivery: { $ref: '#/components/schemas/DeliveryMeasure' }
    RunStateCounts:
      type: object
      additionalProperties: false
      properties:
        currently_dispatched: { type: integer }
        completed: { type: integer }
        assumed_completed: { type: integer }
        scheduled: { type: integer }
        scheduled_run_ended: { type: integer }
        cancelled: { type: integer }
        ghost: { type: integer }
        tracking_gap: { type: integer }
        status_unavailable: { type: integer }
    VisitAxis:
      type: object
      additionalProperties: false
      description: >-
        Mutually exclusive pending/resolved stop axes. A pending schedule-only
        visit is `scheduled`, not late or unknown. A resolved outcome is an
        evidence classification; absence stays explicit in coverage_status or
        coverage_reason.
      required: [visit_state, prediction_status, outcome, coverage_status, coverage_reason, evidence_state]
      properties:
        visit_state: { type: string, enum: [pending_stop, resolved_stop] }
        prediction_status: { type: string, enum: [scheduled, early, on_time, late, extra_late, unavailable], nullable: true }
        outcome: { type: string, enum: [reached, passed, cancelled, skipped, tracking_gap, ghost, unavailable], nullable: true }
        coverage_status: { type: string, enum: [complete, partial, unavailable], nullable: true }
        coverage_reason: { type: string, nullable: true }
        evidence_state: { type: string, nullable: true }
    TimingEvidence:
      type: object
      additionalProperties: false
      description: >-
        Arrival, estimate, passage, or schedule-only evidence. A schedule-only
        time has no punctuality or delay class.
      required: [kind, estimated_arrival_at, passage_at, arrival_window_start, arrival_window_end, estimate_source, variance_seconds, punctuality]
      properties:
        kind: { type: string, enum: [gtfs_rt_eta, vehicle_position_eta, vehicle_position_passed, passage_interpolated, schedule_only], nullable: true }
        estimated_arrival_at: { type: string, format: date-time, nullable: true }
        passage_at: { type: string, format: date-time, nullable: true }
        arrival_window_start: { type: string, format: date-time, nullable: true }
        arrival_window_end: { type: string, format: date-time, nullable: true }
        estimate_source: { type: string, nullable: true }
        variance_seconds: { type: integer, nullable: true }
        punctuality: { type: string, enum: [early, on_time, late, extra_late], nullable: true }
    PublicAlertAssociation:
      type: object
      additionalProperties: false
      description: >-
        Public alert identity and actual association scope. `scope` and
        `match_kind` prevent a route-wide notice from being misrepresented as a
        trip, stop, or vehicle-specific fact.
      required: [source, id, scope, match_kind, occurred_at, title]
      properties:
        source: { type: string, nullable: true }
        id: { type: string, nullable: true }
        scope: { type: string, enum: [route_context, exact_trip, exact_stop], nullable: true }
        match_kind: { type: string, nullable: true }
        occurred_at: { type: string, format: date-time, nullable: true }
        title: { type: string, nullable: true }
    CanonicalAlertMatches:
      type: object
      additionalProperties: false
      description: >-
        Public alert-match assessment. Visit projections use `kind`; a run-level
        assessment uses `matching_state`. Both are explicit nullable facts and
        every projection always supplies the scoped alert list.
      required: [alerts]
      properties:
        kind: { type: string, nullable: true }
        matching_state: { type: string, nullable: true }
        alerts:
          type: array
          items: { $ref: '#/components/schemas/PublicAlertAssociation' }
    CanonicalVehicle:
      type: object
      additionalProperties: false
      required: [vehicle_id, latitude, longitude, bearing, speed, observed_at]
      properties:
        vehicle_id: { type: string, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        bearing: { type: number, nullable: true }
        speed: { type: number, nullable: true }
        observed_at: { type: string, format: date-time, nullable: true }
    CanonicalStop:
      type: object
      additionalProperties: false
      required: [stop_id, name, code, parent_station, sequence, latitude, longitude]
      properties:
        stop_id: { type: string, nullable: true }
        name: { type: string, nullable: true }
        code: { type: string, nullable: true }
        parent_station: { type: string, nullable: true }
        sequence: { type: integer, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
    CanonicalScheduledTime:
      type: object
      additionalProperties: false
      required: [arrival_seconds, departure_seconds, arrival_at, departure_at, service_time]
      properties:
        arrival_seconds: { type: integer, nullable: true }
        departure_seconds: { type: integer, nullable: true }
        arrival_at: { type: string, format: date-time, nullable: true }
        departure_at: { type: string, format: date-time, nullable: true }
        service_time: { type: string, nullable: true }
    CanonicalVisit:
      type: object
      additionalProperties: false
      required: [stop, scheduled, status, timing, alert_match]
      properties:
        stop: { $ref: '#/components/schemas/CanonicalStop' }
        scheduled: { $ref: '#/components/schemas/CanonicalScheduledTime' }
        status: { $ref: '#/components/schemas/VisitAxis' }
        timing: { $ref: '#/components/schemas/TimingEvidence' }
        alert_match: { $ref: '#/components/schemas/CanonicalAlertMatches' }
    CanonicalStatusSummary:
      type: object
      additionalProperties: false
      description: Counts by canonical status taxonomy; absent categories are zero counts, not evidence of success.
      required: [scheduled_stop_count, visit_state_counts, prediction_status_counts, outcome_counts, coverage_status_counts, timing_source_counts]
      properties:
        scheduled_stop_count: { type: integer, nullable: true }
        visit_state_counts: { $ref: '#/components/schemas/VisitStateCounts' }
        prediction_status_counts: { $ref: '#/components/schemas/PredictionStatusCounts' }
        outcome_counts: { $ref: '#/components/schemas/OutcomeCounts' }
        coverage_status_counts: { $ref: '#/components/schemas/CoverageStatusCounts' }
        timing_source_counts: { $ref: '#/components/schemas/TimingSourceCounts' }
    VisitStateCounts:
      type: object
      additionalProperties: false
      properties:
        pending_stop: { type: integer }
        resolved_stop: { type: integer }
    PredictionStatusCounts:
      type: object
      additionalProperties: false
      properties:
        scheduled: { type: integer }
        early: { type: integer }
        on_time: { type: integer }
        late: { type: integer }
        extra_late: { type: integer }
        unavailable: { type: integer }
    OutcomeCounts:
      type: object
      additionalProperties: false
      properties:
        reached: { type: integer }
        passed: { type: integer }
        cancelled: { type: integer }
        skipped: { type: integer }
        tracking_gap: { type: integer }
        ghost: { type: integer }
        unavailable: { type: integer }
    CoverageStatusCounts:
      type: object
      additionalProperties: false
      properties:
        complete: { type: integer }
        partial: { type: integer }
        unavailable: { type: integer }
    TimingSourceCounts:
      type: object
      additionalProperties: false
      properties:
        gtfs_rt_eta: { type: integer }
        vehicle_position_eta: { type: integer }
        vehicle_position_passed: { type: integer }
        passage_interpolated: { type: integer }
        schedule_only: { type: integer }
    CanonicalRun:
      type: object
      additionalProperties: false
      required: [trip_id, trip_headsign, direction_id, block_id, scheduled_start_seconds, scheduled_end_seconds, run_state, vehicle, service_delivery, status_summary]
      properties:
        trip_id: { type: string, nullable: true }
        trip_headsign: { type: string, nullable: true }
        direction_id: { type: integer, nullable: true }
        block_id: { type: string, nullable: true }
        scheduled_start_seconds: { type: integer, nullable: true }
        scheduled_end_seconds: { type: integer, nullable: true }
        run_state: { $ref: '#/components/schemas/RunState' }
        vehicle: { $ref: '#/components/schemas/CanonicalVehicle' }
        service_delivery: { $ref: '#/components/schemas/DeliveryAssessment' }
        status_summary: { $ref: '#/components/schemas/CanonicalStatusSummary' }
    CanonicalRouteRunsResponse:
      type: object
      additionalProperties: false
      required: [as_of, service_date, schedule_vintage, coverage, route, next_cursor, items, status_summary, delivery_summary]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
        next_cursor: { type: string, nullable: true }
        items: { type: array, items: { $ref: '#/components/schemas/CanonicalRun' } }
        status_summary: { $ref: '#/components/schemas/CanonicalStatusSummary' }
        delivery_summary: { $ref: '#/components/schemas/DeliverySummary' }
    CanonicalRouteRunResponse:
      type: object
      additionalProperties: false
      required: [as_of, service_date, schedule_vintage, coverage, route, trip, run_state, service_delivery, vehicle, status_summary, trip_performance, trip_alerts, visits]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
        trip: { $ref: '#/components/schemas/TripIdentity' }
        run_state: { $ref: '#/components/schemas/RunState' }
        service_delivery: { $ref: '#/components/schemas/DeliveryAssessment' }
        vehicle: { $ref: '#/components/schemas/CanonicalVehicle' }
        status_summary: { $ref: '#/components/schemas/CanonicalStatusSummary' }
        trip_performance: { $ref: '#/components/schemas/CanonicalStatusSummary' }
        trip_alerts: { $ref: '#/components/schemas/CanonicalAlertMatches' }
        visits: { type: array, items: { $ref: '#/components/schemas/CanonicalVisit' } }
    VehicleRunLink:
      type: object
      additionalProperties: false
      required: [trip_id, trip_headsign, direction_id, first_observed_at, last_observed_at, observation_count, route, alerts]
      properties:
        trip_id: { type: string, nullable: true }
        trip_headsign: { type: string, nullable: true }
        direction_id: { type: integer, nullable: true }
        first_observed_at: { type: string, format: date-time, nullable: true }
        last_observed_at: { type: string, format: date-time, nullable: true }
        observation_count: { type: integer, nullable: true }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
        alerts: { type: array, items: { $ref: '#/components/schemas/PublicAlertAssociation' } }
    CanonicalVehicleRunsResponse:
      type: object
      additionalProperties: false
      required: [as_of, service_date, schedule_vintage, coverage, vehicle, trips, alerts]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        vehicle: { $ref: '#/components/schemas/VehicleRunSummary' }
        trips: { type: array, items: { $ref: '#/components/schemas/VehicleRunLink' } }
        alerts: { type: array, items: { $ref: '#/components/schemas/CanonicalVehicleAlertAssociation' } }
    CanonicalVehicleAlertAssociation:
      type: object
      additionalProperties: false
      required: [source, id, scope, match_kind, occurred_at, title, trip_id]
      properties:
        source: { type: string, nullable: true }
        id: { type: string, nullable: true }
        scope: { type: string, enum: [route_context, exact_trip, exact_stop], nullable: true }
        match_kind: { type: string, nullable: true }
        occurred_at: { type: string, format: date-time, nullable: true }
        title: { type: string, nullable: true }
        trip_id: { type: string, nullable: true }
    CanonicalStopVisit:
      type: object
      additionalProperties: false
      required: [trip_id, stop_sequence, stop, scheduled, status, timing, alert_matches, coverage]
      properties:
        trip_id: { type: string, nullable: true }
        stop_sequence: { type: integer, nullable: true }
        stop: { $ref: '#/components/schemas/CanonicalStop' }
        scheduled: { $ref: '#/components/schemas/CanonicalScheduledTime' }
        status: { $ref: '#/components/schemas/VisitAxis' }
        timing: { $ref: '#/components/schemas/TimingEvidence' }
        alert_matches: { $ref: '#/components/schemas/CanonicalAlertMatches' }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
    CanonicalStopVisitsResponse:
      type: object
      additionalProperties: false
      required: [as_of, service_date, schedule_vintage, coverage, route, next_cursor, items]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
        next_cursor: { type: string, nullable: true }
        items: { type: array, items: { $ref: '#/components/schemas/CanonicalStopVisit' } }
    ShapeFeatureProperties:
      type: object
      additionalProperties: false
      required: [shape_id, direction_id]
      properties:
        shape_id: { type: string, nullable: true }
        direction_id: { type: integer, nullable: true }
    StopFeatureProperties:
      type: object
      additionalProperties: false
      required: [trip_id, stop_id, stop_sequence, stop_name, observed_at, estimated_arrival_at, schedule_deviation_seconds]
      properties:
        trip_id: { type: string, nullable: true }
        stop_id: { type: string, nullable: true }
        stop_sequence: { type: integer, nullable: true }
        stop_name: { type: string, nullable: true }
        observed_at: { type: string, format: date-time, nullable: true }
        estimated_arrival_at: { type: string, format: date-time, nullable: true }
        schedule_deviation_seconds: { type: integer, nullable: true }
    VehicleFeatureProperties:
      type: object
      additionalProperties: false
      required: [vehicle, trip_id]
      properties:
        vehicle: { $ref: '#/components/schemas/CanonicalVehicle' }
        trip_id: { type: string, nullable: true }
    GeoJSONFeatureCollection:
      type: object
      additionalProperties: false
      required: [type, features]
      properties:
        type: { type: string, enum: [FeatureCollection] }
        features:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [type, geometry, properties]
            properties:
              type: { type: string, enum: [Feature] }
              geometry:
                type: object
                additionalProperties: false
                required: [type, coordinates]
                properties:
                  type: { type: string, enum: [LineString] }
                  coordinates:
                    type: array
                    nullable: true
                    minItems: 2
                    items:
                      type: array
                      minItems: 2
                      maxItems: 2
                      items: { type: number }
              properties: { $ref: '#/components/schemas/ShapeFeatureProperties' }
    StopGeoJSONFeatureCollection:
      type: object
      additionalProperties: false
      required: [type, features]
      properties:
        type: { type: string, enum: [FeatureCollection] }
        features:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [type, geometry, properties]
            properties:
              type: { type: string, enum: [Feature] }
              geometry:
                type: object
                additionalProperties: false
                required: [type, coordinates]
                properties:
                  type: { type: string, enum: [Point] }
                  coordinates:
                    type: array
                    nullable: true
                    minItems: 2
                    maxItems: 2
                    items: { type: number }
              properties: { $ref: '#/components/schemas/StopFeatureProperties' }
    VehicleGeoJSONFeatureCollection:
      type: object
      additionalProperties: false
      required: [type, features]
      properties:
        type: { type: string, enum: [FeatureCollection] }
        features:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [type, geometry, properties]
            properties:
              type: { type: string, enum: [Feature] }
              geometry:
                type: object
                additionalProperties: false
                required: [type, coordinates]
                properties:
                  type:
                    type: string
                    enum: [Point]
                    x-enum-varnames: [VehicleGeoJSONPoint]
                  coordinates:
                    type: array
                    nullable: true
                    minItems: 2
                    maxItems: 2
                    items: { type: number }
              properties: { $ref: '#/components/schemas/VehicleFeatureProperties' }
    MapTime:
      type: object
      additionalProperties: false
      required: [mode, requested_at, available_from, available_to, interval_minutes]
      properties:
        mode: { type: string, enum: [historical, live], nullable: true }
        requested_at: { type: string, format: date-time, nullable: true }
        available_from: { type: string, format: date-time, nullable: true }
        available_to: { type: string, format: date-time, nullable: true }
        interval_minutes: { type: integer, enum: [15], nullable: true }
    MapCoverage:
      type: object
      additionalProperties: false
      required: [state, reason, vehicle_observations, stop_observations]
      properties:
        state: { type: string, enum: [available, complete, partial, empty, unavailable], nullable: true }
        reason: { type: string, nullable: true }
        vehicle_observations: { type: integer, nullable: true }
        stop_observations: { type: integer, nullable: true }
    GeoJSONMapSnapshot:
      type: object
      additionalProperties: false
      description: >-
        One stored quarter-hour map frame. Static route shape is context; vehicle
        and stop features are retained observations, never interpolated current
        data. Empty or partial evidence is carried by coverage.
      required: [map_time, shape, stops, vehicles, coverage]
      properties:
        map_time: { $ref: '#/components/schemas/MapTime' }
        shape: { $ref: '#/components/schemas/GeoJSONFeatureCollection' }
        stops: { $ref: '#/components/schemas/StopGeoJSONFeatureCollection' }
        vehicles: { $ref: '#/components/schemas/VehicleGeoJSONFeatureCollection' }
        coverage: { $ref: '#/components/schemas/MapCoverage' }
    CanonicalRouteAlertsResponse:
      type: object
      additionalProperties: false
      required: [as_of, service_date, schedule_vintage, coverage, route, items]
      properties:
        as_of: { type: string, format: date-time }
        service_date: { type: string, format: date }
        schedule_vintage: { type: string, nullable: true }
        coverage: { $ref: '#/components/schemas/CanonicalCoverage' }
        route: { $ref: '#/components/schemas/CanonicalRoute' }
        items:
          type: array
          items: { $ref: '#/components/schemas/CanonicalRouteAlertAssociation' }
    CanonicalRouteAlertAssociation:
      type: object
      additionalProperties: false
      required: [source, id, scope, match_kind, occurred_at, title, trip_id, stop_sequence]
      properties:
        source: { type: string, nullable: true }
        id: { type: string, nullable: true }
        scope: { type: string, enum: [route_context, exact_trip, exact_stop], nullable: true }
        match_kind: { type: string, nullable: true }
        occurred_at: { type: string, format: date-time, nullable: true }
        title: { type: string, nullable: true }
        trip_id: { type: string, nullable: true }
        stop_sequence: { type: integer, nullable: true }
    Error:
      type: object
      description: |
        The envelope returned by every non-2xx JSON response. All detail is
        nested under `error`; there are no sibling fields at the top level.
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
      example:
        error:
          code: session_invalid
          message: No active session
          request_id: req_b5jpka2lwmyko44j72uy63ua
          docs_url: https://atltransit.live/developers#errors
    ErrorBody:
      type: object
      description: |
        The error detail object. `code`, `message`, `request_id` and `docs_url`
        are always present; `details` appears only on the subset of errors that
        carry extra machine-readable context (validation failures that name the
        rejected values, and limit errors that report the limit).
      required: [code, message, request_id, docs_url]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human-readable explanation. Not stable — branch on `code`, not on this text.
        request_id:
          type: string
          description: |
            Correlation id for this request, matching the `x-request-id` response
            header. Quote it in support requests. Empty only in the rare case the
            request never reached the request-id middleware.
        docs_url:
          type: string
          format: uri
          description: Link to the error-code reference. Deployment-configurable, so do not hard-code it.
        details:
          type: object
          additionalProperties: true
          description: |
            Optional machine-readable context for this specific error — for
            example the rejected values and the supported set on a validation
            failure, or the applicable cap on a limit error. Absent on most
            errors; the keys vary by `code`, so treat it as an open map.
    ErrorCode:
      type: string
      description: |
        Stable, fine-grained machine-readable error identifier. Branch on this
        rather than on the HTTP status, which is coarser — several codes share
        a status. Treat an unrecognised value as a generic failure of its HTTP
        status class rather than erroring out; new codes may be added.
      enum:
        - key_missing
        - key_invalid
        - key_revoked
        - key_expired
        - plan_inactive
        - rate_limited
        - plan_limit_exceeded
        - sse_limit_reached
        - webhook_limit_reached
        - invalid_request
        - invalid_cursor
        - not_found
        - unprocessable
        - upstream_unavailable
        - internal_error
        - not_implemented
        - auth_unavailable
        - session_invalid
        - magic_link_invalid
        - email_invalid
        - csrf_required
        - key_limit_reached
        - billing_unavailable
        - admin_required
        - feature_not_in_plan
        - scope_not_granted
    SystemReliabilityResponse:
      type: object
      description: |
        System-wide scheduled-versus-cancelled reliability. **Bus only** — see
        `scope_note`, which carries that statement in the payload because an API
        consumer has no UI in which to read a footnote.

        Exactly one of `periods` / `routes` is non-null, selected by `breakdown`.
        Both keys are always present so a consumer can see which arm answered
        instead of inferring it from an absent field.
      required:
        - mode
        - metric
        - breakdown
        - days
        - start_date
        - end_date
        - coverage_start_date
        - calculated_at
        - period_definitions
        - periods
        - routes
        - route_count
        - routes_returned
        - routes_truncated
        - scope_note
        - coverage_note
        - missing_note
        - data_sources
      properties:
        mode:
          enum: [bus]
          description: Always `bus`. This metric does not exist for rail or streetcar — see `scope_note`.
        metric:
          type: string
          description: Always `system_reliability_scheduled_vs_cancelled`.
        breakdown:
          type: string
          enum: [period, route]
          description: Which arm answered. `period` fills `periods`; `route` fills `routes`.
        days:
          type: integer
          nullable: true
          description: The lookback the request asked for. `start_date`/`end_date` are what was resolved.
        start_date:
          type: string
          format: date
          nullable: true
          description: |
            Inclusive start of the resolved range (America/New_York). This is the
            range as requested, not necessarily the range the figures rest on —
            compare it with `coverage_start_date`.
        end_date:
          type: string
          format: date
          nullable: true
          description: Inclusive end of the resolved range (America/New_York).
        coverage_start_date:
          type: string
          format: date
          nullable: true
          description: |
            Earliest date with reliable cancellation data. A `start_date` before
            this is clamped by the query, so the figures cover only from here on.
        calculated_at:
          type: string
          nullable: true
          description: RFC 3339 timestamp in America/New_York, when this was computed.
        period_definitions:
          type: array
          description: |
            The hour bounds of each MARTA service period, in service-day order.
            Emitted as an ordered array rather than a map because a JSON object
            has no order and the alphabetical order (`am_peak` first) is not the
            service day.
          items:
            $ref: '#/components/schemas/SystemPeriodDefinition'
        periods:
          type: array
          nullable: true
          description: One row per service period, aggregated over every bus route. `null` unless `breakdown=period`.
          items:
            $ref: '#/components/schemas/SystemReliabilityPeriod'
        routes:
          type: array
          nullable: true
          description: One entry per bus route. `null` unless `breakdown=route`.
          items:
            $ref: '#/components/schemas/SystemReliabilityRoute'
        route_count:
          type: integer
          nullable: true
          description: |
            Size of the route universe this request covered. `null` on the
            `period` arm.
        routes_returned:
          type: integer
          nullable: true
          description: Number of entries in `routes`. `null` on the `period` arm.
        routes_truncated:
          type: boolean
          nullable: true
          description: |
            `null` on the `period` arm. On the `route` arm this is true only if
            an absolute response ceiling of 500 routes was reached. **The plan's
            max query limit is deliberately NOT applied to this aggregate** — a
            partial system aggregate is a different and false claim, not a
            smaller true one.
        scope_note:
          type: string
          description: |
            States, in the payload, that this figure is bus only and why rail and
            streetcar cannot appear in it.
        coverage_note:
          type: string
          description: States what `start_date`/`end_date` do and do not promise.
        missing_note:
          type: string
          description: |
            States what a missing bucket actually looks like on each arm, and
            that an unrecognized route id is indistinguishable from a real route
            with nothing scheduled. This describes the OBSERVED behaviour: the
            producer's own docs claim a period with no data is always omitted,
            which holds only for the historical arm.
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    SystemPeriodDefinition:
      type: object
      description: |
        One MARTA service period's hour bounds. Hours are America/New_York local
        hours on a 24-hour clock, half-open `[start_hour, end_hour)`;
        `late_night` is `[0, 4)`, the small hours of the same calendar date.
      required: [period, start_hour, end_hour]
      properties:
        period:
          type: string
          description: Period name, e.g. `am_peak`. Periods this document does not list are still emitted.
        start_hour:
          type: integer
          nullable: true
        end_hour:
          type: integer
          nullable: true
    SystemReliabilityPeriod:
      type: object
      description: |
        One reliability bucket. `reliability_pct` is `null` — never `0` and never
        `100` — when `total_scheduled` is `0`: a bucket with nothing scheduled is
        unmeasured, not perfect.
      required: [route_id, route_short_name, day_period, total_scheduled, total_cancelled, reliability_pct]
      properties:
        route_id:
          type: string
          nullable: true
          description: '`null` on the `period` arm, where the row aggregates every bus route.'
        route_short_name:
          type: string
          nullable: true
          description: '`null` on the `period` arm.'
        day_period:
          type: string
          description: Service period this row covers, e.g. `pm_peak`.
        total_scheduled:
          type: integer
          description: Scheduled trips in this bucket. A count, so `0` is truthful.
        total_cancelled:
          type: integer
          description: Cancelled trips in this bucket. A count, so `0` is truthful.
        reliability_pct:
          type: number
          format: double
          nullable: true
          description: Share of scheduled trips that were not cancelled. `null` when `total_scheduled` is `0`.
    SystemReliabilityRoute:
      type: object
      description: |
        One route's period breakdown. A short list means "the missing periods are
        not known", never "nothing ran then"; see `missing_note` on the response
        for which arm omits an empty period and which emits it with zeros. A row
        with `total_scheduled: 0` means "no scheduled trips were found", which
        for an unrecognized route id is indistinguishable from a real route with
        nothing scheduled.
      required: [route_id, periods, period_count]
      properties:
        route_id:
          type: string
          description: The route short name as requested.
        periods:
          type: array
          items:
            $ref: '#/components/schemas/SystemReliabilityPeriod'
        period_count:
          type: integer
          description: |
            Number of rows in `periods` — not the number of periods that carried
            service. A count, so `0` is truthful.
    SystemTodayResponse:
      type: object
      description: |
        Today's system-wide bus cancellation count. **Bus only** (`scope_note`)
        and **incomplete** (`in_progress_note`): `scheduled_count` covers the
        whole service day while `cancelled_count` covers only what has been
        reported so far, so `reliability_pct` can only fall as the day goes on.
      required:
        - mode
        - metric
        - date
        - scheduled_count
        - cancelled_count
        - routes_affected
        - reliability_pct
        - last_updated
        - scope_note
        - in_progress_note
        - data_sources
      properties:
        mode:
          enum: [bus]
        metric:
          type: string
          description: Always `system_today_cancellations`.
        date:
          type: string
          format: date
          nullable: true
          description: The service day, i.e. today's date in America/New_York.
        scheduled_count:
          type: integer
          description: Trips scheduled across every bus route for the WHOLE service day.
        cancelled_count:
          type: integer
          description: Cancellations reported SO FAR today. A count, so `0` is truthful.
        routes_affected:
          type: integer
          description: Bus routes with at least one cancellation so far today.
        reliability_pct:
          type: number
          format: double
          nullable: true
          description: |
            In-progress share of scheduled trips not yet cancelled. `null` —
            never `100` — when `scheduled_count` is `0`. Do not compare it to a
            completed day.
        last_updated:
          type: string
          nullable: true
          description: |
            Most recent timestamp across both cancellation sources. `null` means
            neither source has produced anything today — "nothing reported yet",
            not "nothing cancelled".
        scope_note:
          type: string
        in_progress_note:
          type: string
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    CancellationSignal:
      type: object
      description: |
        Whether the mode being reported has a cancellation signal at all. When
        `available` is `false`, every cancellation-derived field in the response
        is `null` **by construction**, and those nulls must not be read as
        "nothing was cancelled".
      required: [available, sources, note]
      properties:
        available:
          type: boolean
        sources:
          type: array
          description: The feeds behind the cancellation status. Empty when `available` is `false`.
          items:
            type: string
        note:
          type: string
          description: How cancellation status is determined, or why it cannot be.
    TripsTodayResponse:
      type: object
      description: |
        One route's scheduled trips for a single service day, with cancellation
        status where a cancellation signal exists.

        **Every cancellation-derived field is `null` for a mode with no
        cancellation signal** (`cancellation_signal.available: false`) —
        including each trip's `is_cancelled`. The upstream reports `100.0%` and
        `is_cancelled: false` for those modes; both are artifacts of a feed that
        has never carried such a cancellation, not measurements.
      required:
        - mode
        - metric
        - route_id
        - route_short_name
        - date
        - total_scheduled
        - total_cancelled
        - reliability_pct
        - cancellation_signal
        - directions
        - direction_count
        - unmatched_cancellations
        - unmatched_cancellations_count
        - cancelled_trips
        - data_sources
      properties:
        mode:
          type: string
          enum: [bus, rail, streetcar]
        metric:
          type: string
          description: Always `route_trips_service_day`.
        route_id:
          type: string
          nullable: true
          description: The GTFS route id the schedule was read from.
        route_short_name:
          type: string
          nullable: true
        date:
          type: string
          format: date
          nullable: true
          description: The service day reported, in America/New_York.
        total_scheduled:
          type: integer
          description: |
            Trips scheduled on this route for the day, from GTFS. Real for every
            mode, including modes with no cancellation signal.
        total_cancelled:
          type: integer
          nullable: true
          description: |
            Cancelled trips. `null` — never `0` — when
            `cancellation_signal.available` is `false`: a count of zero would
            assert a measurement where there is no feed.
        reliability_pct:
          type: number
          format: double
          nullable: true
          description: |
            Share of scheduled trips that ran. `null` — never `100` — when there
            is no cancellation signal, and `null` when `total_scheduled` is `0`.
        cancellation_signal:
          $ref: '#/components/schemas/CancellationSignal'
        directions:
          type: array
          items:
            $ref: '#/components/schemas/TripsTodayDirection'
        direction_count:
          type: integer
        unmatched_cancellations:
          type: array
          description: |
            Email cancellations that matched no scheduled trip.
            `total_cancelled` undercounts by exactly this many, so the list
            travels rather than the shortfall being silent.
          items:
            $ref: '#/components/schemas/TripsTodayUnmatchedCancellation'
        unmatched_cancellations_count:
          type: integer
        cancelled_trips:
          type: array
          description: |
            Every cancelled trip, flattened for display. Not truncated with the
            per-direction trip enumeration — this is the answer, not the page.
          items:
            $ref: '#/components/schemas/TripsTodayCancelledTrip'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    TripsTodayDirection:
      type: object
      description: |
        One direction/headsign of the route's service day.

        The `scheduled` / `cancelled` / `reliability_pct` summary is computed
        over **all** trips, never over the returned page: truncating the trip
        enumeration must not change the answer to "how many trips ran today".
      required:
        - headsign
        - direction_id
        - scheduled
        - cancelled
        - reliability_pct
        - trips
        - trip_count
        - trips_returned
        - trips_truncated
      properties:
        headsign:
          type: string
          nullable: true
        direction_id:
          type: integer
          nullable: true
          description: GTFS direction id (0 or 1).
        scheduled:
          type: integer
          description: Trips scheduled in this direction. From GTFS, so real for every mode.
        cancelled:
          type: integer
          nullable: true
          description: '`null` — never `0` — when there is no cancellation signal for this mode.'
        reliability_pct:
          type: number
          format: double
          nullable: true
          description: '`null` — never `100` — when there is no cancellation signal for this mode.'
        trips:
          type: array
          items:
            $ref: '#/components/schemas/TripsTodayTrip'
        trip_count:
          type: integer
          description: Trips scheduled in this direction before `limit` was applied.
        trips_returned:
          type: integer
        trips_truncated:
          type: boolean
          description: True when `limit` cut the enumeration. The summary figures above are unaffected.
    TripsTodayTrip:
      type: object
      description: |
        One scheduled trip. `is_cancelled` is `null`, **not `false`**, for a mode
        with no cancellation signal: `false` is a per-trip claim that this
        specific vehicle ran, which cannot be known from the absence of a feed.
      required:
        - trip_id
        - departure_time
        - is_cancelled
        - cancellation_source
        - is_estimated_cancellation
        - cancellation_time_diff_seconds
      properties:
        trip_id:
          type: string
        departure_time:
          type: string
          nullable: true
          description: Scheduled departure as `HH:MM:SS`, local (America/New_York) service time.
        is_cancelled:
          type: boolean
          nullable: true
          description: '`null` when `cancellation_signal.available` is `false`.'
        cancellation_source:
          type: string
          nullable: true
          description: Which feed reported the cancellation, e.g. `gtfs_rt` or `email`. `null` when the trip was not cancelled.
        is_estimated_cancellation:
          type: boolean
          nullable: true
          description: |
            True when an email alert was matched to this departure by a ±5 minute
            fuzzy match rather than an exact time. `null` when not applicable.
        cancellation_time_diff_seconds:
          type: number
          format: double
          nullable: true
          description: How far the matched email alert's time was from this departure. `null` when not a fuzzy match.
    TripsTodayUnmatchedCancellation:
      type: object
      description: An email cancellation that could not be matched to any scheduled trip.
      required: [time, direction, origin]
      properties:
        time:
          type: string
          nullable: true
          description: Departure time named by the alert, `HH:MM:SS` local service time.
        direction:
          type: string
          nullable: true
        origin:
          type: string
          nullable: true
          description: Origin stop named by the alert.
    TripsTodayCancelledTrip:
      type: object
      description: One cancelled trip, flattened across directions for display.
      required: [time, headsign, direction_id, source]
      properties:
        time:
          type: string
          nullable: true
        headsign:
          type: string
          nullable: true
        direction_id:
          type: integer
          nullable: true
        source:
          type: string
          nullable: true
    DataSource:
      type: object
      required: [system]
      properties:
        system:
          type: string
          enum: [marta_fail, marta_gtfs_analysis, marta_realtime_api]
        tables:
          type: array
          items:
            type: string
        endpoints:
          type: array
          items:
            type: string
        notes:
          type: string
    RailRealtimeResponse:
      type: object
      required: [mode, count, page_info, line_delay_summary, station_delay_summary, rail_status, data_sources, total_note, arrivals]
      properties:
        mode:
          enum: [rail]
        count:
          type: integer
          description: Number of arrivals returned in this page.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        line_delay_summary:
          type: array
          description: ATL Transit Live computed delay aggregates by rail line for the filtered time window.
          items:
            $ref: '#/components/schemas/RailDelaySummary'
        station_delay_summary:
          type: array
          description: ATL Transit Live computed delay aggregates by station for the filtered time window.
          items:
            $ref: '#/components/schemas/RailDelaySummary'
        rail_status:
          $ref: '#/components/schemas/RailStatus'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        total_note:
          type: string
          nullable: true
          description: >-
            Non-null exactly when `page_info.total_available` is a LOWER BOUND
            rather than a total. The lookback window is fetched from upstream at
            a hard row ceiling before any `line`/`station` filter is applied;
            when that ceiling is reached the total counts what was fetched, not
            what matched. Carried in the payload and not only in these docs,
            because a consumer reading `total_available` off the wire cannot see
            a footnote — and a total that equals the ceiling is otherwise
            indistinguishable from a window that genuinely held that many rows.
            Null means the total is exact.
        arrivals:
          type: array
          items:
            $ref: '#/components/schemas/RailArrival'
    RailPositionsResponse:
      type: object
      required:
        - mode
        - line
        - minutes
        - as_of
        - count
        - with_position
        - trains
        - observations_without_train_id
        - returned
        - collapse_key
        - collapse_note
        - count_definitions
        - page_info
        - data_sources
        - observations
      description: |
        The current rail snapshot, collapsed to the most recent poll per
        `(line, station, direction, train_id)`.

        `count` and `with_position` describe the WHOLE window, not this page, and
        are the same numbers the site's internal `/public/rail/positions` reports
        for the same query — the collapse is performed by one shared upstream
        query rather than derived twice.
      properties:
        mode:
          enum: [rail]
        line:
          type: string
          nullable: true
          enum: [RED, GOLD, BLUE, GREEN, null]
          description: The line filter actually applied. `null` means every line.
        minutes:
          type: integer
          description: The lookback window applied before collapsing.
        as_of:
          type: string
          format: date-time
          nullable: true
          description: >-
            Upstream's snapshot time, in America/New_York. `null` when upstream
            stated none — the server clock is never substituted, because "when
            this was true" is a fact about the data rather than about when the
            response was assembled.
        count:
          type: integer
          description: >-
            Collapsed observation **rows** in the whole window. NOT trains, and
            NOT the size of this page. Identical to `count` on
            `/public/rail/positions` for the same query.
        with_position:
          type: integer
          description: >-
            Collapsed rows in the whole window carrying a latitude/longitude fix.
            Most do, but not all; the rest are still returned, with `null`
            coordinates, because an arrivals board needs them. Compare this
            against `count` for the actual coverage of your window rather than
            assuming a fixed ratio.
        trains:
          type: integer
          description: >-
            Distinct physical trains in the whole window, counted as distinct
            `(line, train_id)` pairs — MARTA recycles train numbers per line, so
            `train_id` alone would merge a RED 401 with a GOLD 401. This is
            roughly three orders of magnitude smaller than `count` and is the
            only field here that answers "how many trains".
        observations_without_train_id:
          type: integer
          description: >-
            Collapsed rows naming no train — schedule text rather than a tracked
            train. Counted in `count`, but not attributable to any train in
            `trains`. Published as the denominator that makes `trains` readable.
        returned:
          type: integer
          description: Observation rows in THIS page. Lower than `count` whenever the page was truncated.
        collapse_key:
          type: array
          items:
            type: string
          description: The tuple the rows are collapsed on, so a consumer can reproduce the grouping rather than guess it.
        collapse_note:
          type: string
          description: What a row here IS, and the staleness/countdown caveats, carried in the payload.
        count_definitions:
          type: object
          additionalProperties:
            type: string
          description: What each count counts, keyed by field name. Every count names its unit.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        observations:
          type: array
          items:
            $ref: '#/components/schemas/RailPosition'
    RailPosition:
      type: object
      required:
        - id
        - observed_at
        - station
        - line
        - direction
        - destination
        - train_id
        - event_time
        - next_arrival_text
        - waiting_seconds
        - waiting_time
        - delay_text
        - delay_seconds
        - delay_status
        - is_realtime
        - latitude
        - longitude
      description: |
        One collapsed observation: a train's next-arrival reading at one station
        in one direction, as of the most recent poll that reported it.

        Every nullable field here serializes as `null`, never `0`, when the
        reading was not taken. Some rows carry no GPS fix, and around half carry
        no delay reading; zero-filling either would invent millions of
        measurements that were never made.
      properties:
        id:
          type: integer
          nullable: true
          description: Row id of the winning observation in `rail_realtime_observations`.
        observed_at:
          type: string
          format: date-time
          nullable: true
          description: When this poll was recorded. Judge freshness against this; treat rows older than about 10 minutes as stale.
        station:
          type: string
          nullable: true
          description: The realtime feed's spelling of the station (shout-case, e.g. `FIVE POINTS STATION`).
        line:
          type: string
          nullable: true
        direction:
          type: string
          nullable: true
          description: >-
            Part of the collapse key. One train reports northbound and southbound
            readings at the same station and they are different facts. Observed
            values are `N`/`S` (Red, Gold) and `E`/`W` (Blue, Green).
        destination:
          type: string
          nullable: true
        train_id:
          type: string
          nullable: true
          description: >-
            `null` for a schedule-text row (`is_realtime` false), which names no
            physical train. MARTA recycles a small pool of numbers per line, so
            this identifies which train is coming right now and is never a
            durable trip identifier.
        event_time:
          type: string
          format: date-time
          nullable: true
        next_arrival_text:
          type: string
          nullable: true
        waiting_seconds:
          type: integer
          nullable: true
          description: >-
            Seconds until the predicted arrival **as captured at `observed_at`** —
            a frozen countdown, not a live one. Age it forward by
            `now - observed_at` before displaying it; treating it as current makes
            every station appear to have a train arriving.
        waiting_time:
          type: string
          nullable: true
        delay_text:
          type: string
          nullable: true
        delay_seconds:
          type: integer
          nullable: true
          description: Delay reading for this observation. `null` — never `0` — when no reading was taken.
        delay_status:
          type: string
          enum: [unknown, normal, minor, major, severe]
          description: >-
            `unknown` means `delay_seconds` is `null`: no reading was taken, so
            punctuality is not known. Do not treat `unknown` as `normal`. A
            genuine reading of `0` seconds is `normal`.
        is_realtime:
          type: boolean
          nullable: true
          description: False for schedule text rather than a tracked train.
        latitude:
          type: number
          nullable: true
          description: '`null` — never `0` — when no GPS fix was reported. Zero-filling would place every unlocated train in the Gulf of Guinea.'
        longitude:
          type: number
          nullable: true
          description: '`null` — never `0` — when no GPS fix was reported.'
    RailStatus:
      type: object
      required: [lines]
      description: |
        Line-first view of current rail status. Each line contains station
        summaries and running train delay states.

        The same coverage caveat as `RailDelaySummary` applies at both the line
        and the station level: `average_delay_seconds` and `max_delay_seconds`
        cover only the `observations_with_delay` observations that carried a
        `delay_seconds` reading, out of `observations_total`, and are `null` —
        never `0` — when nothing was measured.

        The caveat reaches the individual `trains[]` entries too. A train whose
        observation carried no reading reports `delay_seconds: null` and
        `delay_status: "unknown"`. It is **not** reported as `0` / `"normal"`,
        which would assert that a specific, named train is on time when no
        reading was ever taken for it.
      properties:
        lines:
          type: array
          items:
            type: object
            required: [line, observations_total, observations_with_delay, average_delay_seconds, max_delay_seconds, active_train_count, stations]
            properties:
              line:
                type: string
                enum: [RED, GOLD, BLUE, GREEN]
              observations_total:
                type: integer
                description: Observations for this line, with or without a delay reading.
              observations_with_delay:
                type: integer
                description: Observations for this line that carried a `delay_seconds` reading; the denominator of `average_delay_seconds`.
              average_delay_seconds:
                type: integer
                nullable: true
                description: Mean delay across measured observations only. `null` — never `0` — when `observations_with_delay` is `0`.
              max_delay_seconds:
                type: integer
                nullable: true
                description: Largest delay among the measured observations. `null` — never `0` — when `observations_with_delay` is `0`.
              active_train_count:
                type: integer
              stations:
                type: array
                items:
                  type: object
                  required: [station, observations_total, observations_with_delay, average_delay_seconds, max_delay_seconds, trains]
                  properties:
                    station:
                      type: string
                    observations_total:
                      type: integer
                      description: Observations at this station, with or without a delay reading.
                    observations_with_delay:
                      type: integer
                      description: Observations at this station that carried a `delay_seconds` reading; the denominator of `average_delay_seconds`.
                    average_delay_seconds:
                      type: integer
                      nullable: true
                      description: Mean delay across measured observations only. `null` — never `0` — when `observations_with_delay` is `0`.
                    max_delay_seconds:
                      type: integer
                      nullable: true
                      description: Largest delay among the measured observations. `null` — never `0` — when `observations_with_delay` is `0`.
                    trains:
                      type: array
                      items:
                        $ref: '#/components/schemas/RailStatusTrain'
    RailStatusTrain:
      type: object
      description: |
        One observed train at a station, with its delay reading.

        `delay_seconds` is `null` — never `0` — when the observation carried no
        reading, and `delay_status` is then `unknown`. Around half of rail
        realtime observations carry no reading, so this is the common case.
        Reporting `0` / `normal` here would assert that a specific, named train
        is on time when no reading was ever taken for it.
      required: [train_id, delay_seconds, delay_status]
      properties:
        train_id:
          type: string
          nullable: true
        delay_seconds:
          type: integer
          nullable: true
          description: |
            Delay reading for this train's observation. `null` — never `0` —
            when the observation carried no reading.
        delay_status:
          type: string
          enum: [unknown, normal, minor, major, severe]
          description: |
            ATL Transit Live delay bucket. Minor is 5+ min, major is 10+ min,
            severe is 20+ min.

            `unknown` means `delay_seconds` is `null` — no reading was taken for
            this train, so its punctuality is not known. Do not treat `unknown`
            as `normal`: a train that was never measured is not a train that is
            on time. A genuine reading of `0` seconds is `normal`.
    RailDelaySummary:
      type: object
      description: |
        Delay aggregate for one rail line or station.

        **Coverage.** Only some rail realtime observations carry a
        `delay_seconds` reading — historically about one third. Every delay
        figure here (`average_delay_seconds`, `max_delay_seconds`,
        `major_delay_count`) is computed over **only** the observations that
        carried one. `observations_total` is how many observations went into
        the group; `observations_with_delay` is how many of those were actually
        measured, and is the denominator of the average. Disclose that ratio
        rather than presenting the average as if it covered every observation.

        Observations without a reading are excluded, never counted as zero
        delay — folding them in would drag every line toward "on time" and
        report a measurement that was never taken.
      required:
        - arrivals
        - trains
        - observations_total
        - observations_with_delay
        - average_delay_seconds
        - max_delay_seconds
        - major_delay_count
      properties:
        line:
          type: string
          description: Present for line summaries.
        station:
          type: string
          description: Present for station summaries.
        arrivals:
          type: integer
          description: Alias of `observations_total`, retained for compatibility.
        trains:
          type: integer
          description: Distinct trains seen in this group.
        observations_total:
          type: integer
          description: Observations in this group, with or without a delay reading.
        observations_with_delay:
          type: integer
          description: |
            Observations in this group that carried a `delay_seconds` reading.
            This is the denominator of `average_delay_seconds`. When it is `0`
            the group is unmeasured and the average is `null`.
        average_delay_seconds:
          type: integer
          nullable: true
          description: |
            Mean delay across the `observations_with_delay` measured
            observations only. `null` — never `0` — when
            `observations_with_delay` is `0`: a group with no readings is
            unmeasured, not on time.
        max_delay_seconds:
          type: integer
          nullable: true
          description: |
            Largest delay among the `observations_with_delay` measured
            observations. `null` — never `0` — when `observations_with_delay`
            is `0`: a `0` here would read as "nothing was delayed" when in fact
            nothing was measured.
        major_delay_count:
          type: integer
          description: Measured observations at or above 600 seconds of delay.
    RailOnTimeResponse:
      type: object
      description: |
        Rail on-time performance per line. See the endpoint description for the
        coverage and comparison-arm caveats — they are not optional context.
      required: [mode, metric, line, hours, on_time_threshold_seconds, comparison, previous_window_hours, delay_trend_steady_band_seconds, as_of, lines, data_sources]
      properties:
        mode:
          enum: [rail]
        metric:
          type: string
          description: Always `rail_on_time_performance`.
        line:
          type: string
          nullable: true
          description: The line filter that was applied, or `null` for all lines.
        hours:
          type: integer
          description: Trailing window this response covers.
        on_time_threshold_seconds:
          type: integer
          nullable: true
          description: |
            A measured observation counts as on time below this delay. `on_time_pct`
            is meaningless without it, so it travels in the payload rather than
            only in this document.
        comparison:
          type: string
          nullable: true
          description: What the `previous_*` fields compare against, e.g. `previous_equal_length_window`.
        previous_window_hours:
          type: integer
          nullable: true
          description: Length of the comparison window.
        delay_trend_steady_band_seconds:
          type: integer
          nullable: true
          description: |
            A change in average delay smaller than this counts as `steady` rather
            than improving or worsening.
        as_of:
          type: string
          nullable: true
          description: RFC 3339 timestamp in America/New_York, when the metric was computed.
        lines:
          type: array
          items:
            $ref: '#/components/schemas/RailOnTimeLine'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    RailOnTimeLine:
      type: object
      description: |
        One rail line's on-time performance, plus the immediately preceding
        equal-length window.

        **Coverage.** Every figure here covers `observations_with_delay` only —
        the observations that actually carried a `delay_seconds` reading, out of
        `observations_total`. Around half carry none, and the share moves with
        the feed, so read the two counts rather than assuming a ratio. Disclose
        it; do not present `on_time_pct` as if it covered every observation.

        **Unmeasured is null.** When `observations_with_delay` is `0`, every
        figure is `null` — never `0`, and never `100`. A line with no readings is
        unmeasured, not perfect.

        **The comparison arm can be absent.** When the prior window has no data,
        every `previous_*` field and `delay_trend` are `null`, **not**
        `"improving"`.
      required:
        - line
        - observations_total
        - observations_with_delay
        - on_time_pct
        - avg_delay_seconds
        - median_delay_seconds
        - max_delay_seconds
        - max_delay_station
        - implausible_excluded
        - previous_on_time_pct
        - previous_avg_delay_seconds
        - previous_median_delay_seconds
        - previous_observations_total
        - previous_observations_with_delay
        - previous_implausible_excluded
        - delay_trend
        - delay_change_seconds
      properties:
        line:
          type: string
          enum: [RED, GOLD, BLUE, GREEN]
        observations_total:
          type: integer
          description: Observations for this line in the window, with or without a delay reading.
        observations_with_delay:
          type: integer
          description: |
            Observations that carried a `delay_seconds` reading. This is the
            denominator of `on_time_pct` and of every delay figure below. A
            missing reading is excluded, never counted as on time — counting it
            would push every line toward 100%.
        on_time_pct:
          type: number
          format: double
          nullable: true
          description: |
            Share of measured observations under `on_time_threshold_seconds`.
            `null` — never `0` and never `100` — when `observations_with_delay`
            is `0`.
        avg_delay_seconds:
          type: number
          format: double
          nullable: true
          description: Mean delay across measured observations. `null` — never `0` — when nothing was measured.
        median_delay_seconds:
          type: number
          format: double
          nullable: true
          description: Median delay across measured observations. `null` when nothing was measured.
        max_delay_seconds:
          type: integer
          nullable: true
          description: Largest measured delay. `null` — never `0` — when nothing was measured.
        max_delay_station:
          type: string
          nullable: true
          description: Station at which `max_delay_seconds` was observed. `null` when nothing was measured.
        implausible_excluded:
          type: integer
          description: |
            Readings discarded as implausible before any figure was computed.
            Applied identically to both the current and the previous window. A
            count, so `0` is truthful.
        previous_on_time_pct:
          type: number
          format: double
          nullable: true
          description: '`on_time_pct` for the immediately preceding equal-length window. `null` when that window has no data.'
        previous_avg_delay_seconds:
          type: number
          format: double
          nullable: true
          description: '`avg_delay_seconds` for the previous window. `null` when that window has no data.'
        previous_median_delay_seconds:
          type: number
          format: double
          nullable: true
          description: '`median_delay_seconds` for the previous window. `null` when that window has no data.'
        previous_observations_total:
          type: integer
          nullable: true
          description: Observations in the previous window. `null` when that window has no data.
        previous_observations_with_delay:
          type: integer
          nullable: true
          description: Denominator of the previous window's figures. `null` when that window has no data.
        previous_implausible_excluded:
          type: integer
          nullable: true
          description: Readings discarded as implausible in the previous window. `null` when that window has no data.
        delay_trend:
          type: string
          nullable: true
          enum: [improving, steady, worsening]
          description: |
            Direction of travel between the two windows, using
            `delay_trend_steady_band_seconds` as the dead band. **`null` — not
            `"improving"` — when the prior window has no data.** Do not render an
            absent trend as good news.
        delay_change_seconds:
          type: number
          format: double
          nullable: true
          description: '`avg_delay_seconds` minus `previous_avg_delay_seconds`. `null` when either arm is unmeasured.'
    RailHeadwaysResponse:
      type: object
      description: |
        Trailing-average rail headways. **These headways are INFERRED, not
        measured** — see `inferred` and the endpoint description.
      required: [mode, metric, inferred, line, station, as_of, windows, data_sources]
      properties:
        mode:
          enum: [rail]
        metric:
          type: string
          description: Always `rail_headway_trailing_average`.
        inferred:
          type: boolean
          description: |
            Always `true`, and load-bearing. The realtime feed contains no
            arrival events, only rolling next-arrival predictions, so every
            arrival behind these numbers was reconstructed. Do not present these
            as observed headways.
        line:
          type: string
          nullable: true
          description: The line filter that was applied, or `null` for all lines.
        station:
          type: string
          nullable: true
          description: The station filter that was applied, or `null` for all stations.
        as_of:
          type: string
          nullable: true
          description: RFC 3339 timestamp in America/New_York, when the metric was computed.
        windows:
          type: array
          items:
            $ref: '#/components/schemas/RailHeadwayWindow'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    RailHeadwayWindow:
      type: object
      description: |
        One trailing window. Whether a window is answerable is decided per
        request from the data, not from a hardcoded list: a window backed by less
        than 90% of its own length reports `insufficient_history: true` with
        every figure `null`. It is never silently narrowed while still wearing
        its label, and a figure is never extrapolated.
      required:
        - window_hours
        - observation_span_start
        - observation_span_end
        - observation_span_hours
        - sufficient_history
        - insufficient_history
        - min_headway_samples
        - arrivals_inferred
        - merged_duplicate_arrivals
        - service_gap_excluded
        - observations_total
        - observations_usable
        - runs_total
        - unresolved_runs
        - system
        - lines
        - stations
        - station_count
        - stations_returned
        - stations_truncated
      properties:
        window_hours:
          type: integer
          description: The window's nominal length. Compare against `observation_span_hours` before trusting the label.
        observation_span_start:
          type: string
          nullable: true
          description: Earliest observation actually backing this window. `null` when the window is empty.
        observation_span_end:
          type: string
          nullable: true
          description: Latest observation actually backing this window. `null` when the window is empty.
        observation_span_hours:
          type: number
          format: double
          nullable: true
          description: |
            Hours of data the window actually rests on. A 24h window backed by
            3.1h says `3.1` here and sets `insufficient_history`.
        sufficient_history:
          type: boolean
        insufficient_history:
          type: boolean
          description: |
            `true` when the window is backed by less than 90% of its own length.
            Every headway figure in the window is then `null`.
        min_headway_samples:
          type: integer
          nullable: true
          description: |
            Minimum intervals a group needs before a figure is reported. Below
            it the group is `null`, not a confident average over two data points.
        arrivals_inferred:
          type: integer
          description: Arrivals reconstructed for this window. A count, so `0` is truthful.
        merged_duplicate_arrivals:
          type: integer
          description: Reconstructed arrivals merged as duplicates.
        service_gap_excluded:
          type: integer
          description: Intervals discarded as service gaps rather than headways.
        observations_total:
          type: integer
        observations_usable:
          type: integer
        runs_total:
          type: integer
        unresolved_runs:
          type: integer
          description: Train runs whose arrival could not be reconstructed and which contribute to no interval.
        system:
          $ref: '#/components/schemas/RailHeadwayGroup'
        lines:
          type: array
          items:
            $ref: '#/components/schemas/RailHeadwayGroup'
        stations:
          type: array
          description: >-
            Per-platform groups (line + station + direction). NOT capped by your
            plan's max query limit -- see `stations_truncated` -- and there is no
            `limit` parameter on this endpoint. Narrow the set with
            `line`/`station` if you want less.
          items:
            $ref: '#/components/schemas/RailHeadwayGroup'
        station_count:
          type: integer
          description: Platform groups this window has in total, before any truncation.
        stations_returned:
          type: integer
          description: Platform groups in this response's `stations` array.
        stations_truncated:
          type: boolean
          description: |
            True only if an absolute response ceiling of 1000 platform groups was
            reached. **The plan's max query limit is deliberately NOT applied to
            this aggregate** — a partial aggregate is a different and false
            claim, not a smaller true one, and every plan sees the same
            `stations`. Narrow the set with `line`/`station` if you want less.
    RailHeadwayGroup:
      type: object
      description: |
        One headway aggregate: the whole system, one line, or one platform.
        `line`, `station` and `direction` are `null` at levels where they do not
        apply — the system group has all three `null`, a line group has only
        `line`.

        Every figure is `null`, never `0`, when it could not be computed: either
        the group has fewer than `min_headway_samples` intervals
        (`insufficient_samples`) or the whole window is under-covered
        (`insufficient_history`). `sample_count` is always reported so the
        denominator is visible.
      required: [line, station, direction, sample_count, insufficient_samples, avg_headway_seconds, median_headway_seconds, min_headway_seconds, max_headway_seconds]
      properties:
        line:
          type: string
          nullable: true
        station:
          type: string
          nullable: true
        direction:
          type: string
          nullable: true
        sample_count:
          type: integer
          description: |
            Intervals backing this group. Always reported, including when the
            figures are `null`, so a consumer can see what was and was not there.
        insufficient_samples:
          type: boolean
          description: '`true` when `sample_count` is below `min_headway_samples`; every figure is then `null`.'
        avg_headway_seconds:
          type: number
          format: double
          nullable: true
          description: Mean interval between inferred arrivals. `null` — never `0` — when it could not be computed.
        median_headway_seconds:
          type: number
          format: double
          nullable: true
        min_headway_seconds:
          type: number
          format: double
          nullable: true
        max_headway_seconds:
          type: number
          format: double
          nullable: true
    BusSpeedResponse:
      type: object
      description: |
        Average bus speed over trailing windows. **These are speeds while moving
        and are biased upward** — see `moving_vehicles_only` and
        `speed_coverage_pct`.
      required: [mode, metric, route, source_units, reported_units, as_of, windows, data_sources]
      properties:
        mode:
          enum: [bus]
        metric:
          type: string
          description: Always `bus_average_speed`.
        route:
          type: string
          nullable: true
          description: The route filter that was applied, or `null` for all routes.
        source_units:
          type: string
          nullable: true
          description: Units the upstream feed reports, e.g. `meters_per_second`.
        reported_units:
          type: string
          nullable: true
          description: Units the `*_mph` figures are in.
        as_of:
          type: string
          nullable: true
          description: RFC 3339 timestamp in America/New_York, when the metric was computed.
        windows:
          type: array
          items:
            $ref: '#/components/schemas/BusSpeedWindow'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    BusSpeedWindow:
      type: object
      required:
        - window_hours
        - moving_vehicles_only
        - source_resolution_mph
        - observation_span_start
        - observation_span_end
        - observation_span_hours
        - min_speed_samples
        - rows_unmatched_route
        - rows_non_bus_mode
        - system
        - routes
        - route_count
        - routes_returned
        - routes_truncated
      properties:
        window_hours:
          type: integer
        moving_vehicles_only:
          type: boolean
          description: |
            Always `true`, and load-bearing. The collector stores a `0.0` speed
            reading as NULL, so stationary buses are largely absent from the
            sample and every average here is biased upward. Label the figure as a
            moving speed.
        source_resolution_mph:
          type: number
          format: double
          nullable: true
          description: |
            Granularity the source feed actually carries. Do not read more
            precision into an average than this.
        observation_span_start:
          type: string
          nullable: true
        observation_span_end:
          type: string
          nullable: true
        observation_span_hours:
          type: number
          format: double
          nullable: true
          description: Hours of data the window actually rests on, which may be less than `window_hours`.
        min_speed_samples:
          type: integer
          nullable: true
          description: Minimum readings a group needs before a figure is reported; below it the group is `null`.
        rows_unmatched_route:
          type: integer
          description: Position rows whose route could not be matched. A count, so `0` is truthful.
        rows_non_bus_mode:
          type: integer
          description: Position rows excluded as non-bus modes.
        system:
          $ref: '#/components/schemas/BusSpeedGroup'
        routes:
          type: array
          description: >-
            Per-route groups. NOT capped by your plan's max query limit -- see
            `routes_truncated` -- and there is no `limit` parameter on this
            endpoint. Narrow the set with `route` if you want less.
          items:
            $ref: '#/components/schemas/BusSpeedGroup'
        route_count:
          type: integer
          description: Routes this window has in total, before any truncation.
        routes_returned:
          type: integer
        routes_truncated:
          type: boolean
          description: |
            True only if an absolute response ceiling of 500 routes was reached.
            **The plan's max query limit is deliberately NOT applied to this
            aggregate** — a partial aggregate is a different and false claim, not
            a smaller true one, and every plan sees the same `routes`. Narrow the
            set with `route` if you want less.
    BusSpeedGroup:
      type: object
      description: |
        One speed aggregate: the whole bus network (`route_short_name: null`) or
        one route.

        **Coverage.** `speed_coverage_pct` is `observations_with_speed` out of
        `observations_total`, and must be disclosed alongside any figure. The
        missing rows are overwhelmingly stationary buses, not a random sample.

        **Unmeasured is null.** A group with no readings reports
        `avg_speed_mph: null`, never `0`. A `0` would read as "this route is
        stopped", which is a different claim entirely.
      required:
        - route_short_name
        - route_long_name
        - observations_total
        - observations_with_speed
        - speed_coverage_pct
        - vehicles
        - implausible_excluded
        - insufficient_samples
        - avg_speed_mph
        - median_speed_mph
        - max_speed_mph
        - avg_speed_mps
      properties:
        route_short_name:
          type: string
          nullable: true
          description: '`null` for the system-wide group.'
        route_long_name:
          type: string
          nullable: true
        observations_total:
          type: integer
          description: Position rows for this group, with or without a speed reading.
        observations_with_speed:
          type: integer
          description: Position rows that carried a speed reading; the denominator of every figure below.
        speed_coverage_pct:
          type: number
          format: double
          nullable: true
          description: |
            `observations_with_speed` as a percentage of `observations_total`. A
            coverage of `0` is a real measurement and is reported as `0`, not
            `null` — it is the figures themselves that go `null`.
        vehicles:
          type: integer
          description: Distinct vehicles seen in this group.
        implausible_excluded:
          type: integer
          description: Readings discarded as implausible. A count, so `0` is truthful.
        insufficient_samples:
          type: boolean
          description: '`true` when the group has fewer than `min_speed_samples` readings; every figure is then `null`.'
        avg_speed_mph:
          type: number
          format: double
          nullable: true
          description: Mean speed WHILE MOVING. `null` — never `0` — when nothing was measured.
        median_speed_mph:
          type: number
          format: double
          nullable: true
        max_speed_mph:
          type: number
          format: double
          nullable: true
        avg_speed_mps:
          type: number
          format: double
          nullable: true
          description: |
            The same mean in the source's own units, so the mph conversion is
            auditable rather than a claim.
    BusServiceDeliveryResponse:
      type: object
      description: |
        Observed versus scheduled buses per route.

        The `scheduled_definition`, `observed_definition`, `not_measured_note`
        and `attribution_note` fields are carried in the payload, not just in
        this document, because an API consumer has no UI in which to read a
        footnote. The short version: `scheduled_buses` counts distinct GTFS
        `block_id`s (buses), not trips; and a bus that is not reporting a
        position is **not** a bus that is not running.
      required:
        - mode
        - metric
        - route
        - scheduled_definition
        - observed_definition
        - not_measured_note
        - attribution_note
        - window_minutes
        - feed_polls
        - feed_available
        - min_feed_polls
        - min_route_poll_coverage_pct
        - observation_span_start
        - observation_span_end
        - rows_unmatched_route
        - rows_non_bus_mode
        - route_count
        - routes_measured
        - routes_not_reporting
        - routes_low_coverage
        - routes_not_scheduled
        - service_date
        - service_minute
        - vintage
        - vintage_is_current
        - as_of
        - system
        - routes
        - routes_returned
        - routes_truncated
        - data_sources
      properties:
        mode:
          enum: [bus]
        metric:
          type: string
          description: Always `bus_service_delivery`.
        route:
          type: string
          nullable: true
          description: The route filter that was applied, or `null` for all routes.
        scheduled_definition:
          type: string
          nullable: true
          description: |
            What "scheduled to be out" means here, verbatim from the producer.
            Trips are not vehicles, and this is the only field that says so at
            runtime.
        observed_definition:
          type: string
          nullable: true
          description: How `observed_buses` is derived, verbatim from the producer.
        not_measured_note:
          type: string
          nullable: true
          description: |
            Why a route can report `null` rather than a count. A route that is
            not reporting positions is not a route with no buses running.
        attribution_note:
          type: string
          nullable: true
          description: |
            Why the gap between observed and scheduled must not be labelled
            missing buses.
        window_minutes:
          type: integer
        feed_polls:
          type: integer
          description: Feed polls in the window. The denominator of `poll_coverage_pct`.
        feed_available:
          type: boolean
          description: '`false` when fewer than `min_feed_polls` polls arrived; nothing is measured and every route reports `feed_unavailable`.'
        min_feed_polls:
          type: integer
          nullable: true
        min_route_poll_coverage_pct:
          type: number
          format: double
          nullable: true
          description: Below this poll coverage a route's `service_delivery_pct` is withheld.
        observation_span_start:
          type: string
          nullable: true
        observation_span_end:
          type: string
          nullable: true
        rows_unmatched_route:
          type: integer
        rows_non_bus_mode:
          type: integer
        route_count:
          type: integer
          description: Routes in the response's scope in total, before any truncation.
        routes_measured:
          type: integer
        routes_not_reporting:
          type: integer
          description: Routes with no position reports. Their `observed_buses` is `null`, not `0`.
        routes_low_coverage:
          type: integer
        routes_not_scheduled:
          type: integer
        service_date:
          type: string
          nullable: true
          description: GTFS service date in America/New_York.
        service_minute:
          type: integer
          nullable: true
          description: Minutes past midnight (America/New_York) at which the schedule side was evaluated.
        vintage:
          type: string
          nullable: true
          description: GTFS feed vintage the scheduled counts were computed against.
        vintage_is_current:
          type: boolean
          nullable: true
          description: '`false` means the scheduled side rests on a superseded GTFS feed.'
        as_of:
          type: string
          nullable: true
        system:
          $ref: '#/components/schemas/BusServiceDeliveryRow'
        routes:
          type: array
          description: >-
            Per-route rows. NOT capped by your plan's max query limit -- see
            `routes_truncated` -- and there is no `limit` parameter on this
            endpoint. Narrow the set with `route` if you want less.
          items:
            $ref: '#/components/schemas/BusServiceDeliveryRow'
        routes_returned:
          type: integer
        routes_truncated:
          type: boolean
          description: |
            True only if an absolute response ceiling of 500 routes was reached.
            **The plan's max query limit is deliberately NOT applied to this
            aggregate** — `route_count`, `routes_measured`, `routes_not_reporting`
            and `routes_low_coverage` are all computed over EVERY route, so a
            plan-capped `routes` array would contradict the counters beside it.
            Every plan sees the same `routes`.
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    BusServiceDeliveryRow:
      type: object
      description: |
        One route's observed-versus-scheduled bus count, or the system roll-up
        (`route_short_name: null`).

        **A bus not reporting a position is not a bus that is not running.** GPS
        dropouts, radio gaps and vehicles between assignments are
        indistinguishable in this feed from a bus that never left the garage. So
        a route with no reports has `observed_buses: null` and
        `status: "not_reporting"` — **never `0`** — and `poll_coverage_pct`
        travels alongside both counts as the evidence for that `null`.

        `scheduled_buses` counts distinct GTFS `block_id`s — one vehicle's run of
        work — not trips. `open_ended_trips_excluded` is the tail of each block,
        which has no end bound in the data; excluding it UNDERSTATES the
        scheduled count and so errs toward "well served", never toward a
        manufactured shortfall.
      required:
        - route_short_name
        - route_long_name
        - status
        - service_delivery_pct
        - scheduled_buses
        - scheduled_buses_all_routes
        - scheduled_buses_excluded
        - scheduled_trips_in_progress
        - open_ended_trips_excluded
        - blocks_shared_with_other_routes
        - observed_buses
        - observed_buses_min
        - observed_buses_max
        - observed_vehicles_distinct
        - polls_reporting
        - poll_coverage_pct
      properties:
        route_short_name:
          type: string
          nullable: true
          description: '`null` for the system roll-up.'
        route_long_name:
          type: string
          nullable: true
        status:
          type: string
          enum: [measured, not_reporting, low_coverage, not_scheduled, feed_unavailable, unknown]
          description: |
            - `measured` — both counts are trustworthy and `service_delivery_pct` is set.
            - `not_reporting` — no position reports at all. `observed_buses` is `null`.
              **This is not "0 buses running".**
            - `low_coverage` — reports arrived in too few polls; counts are shown but
              `service_delivery_pct` is `null`, so a reporting gap cannot be read as a
              service gap.
            - `not_scheduled` — the schedule puts no bus out on this route right now, so
              there is nothing to divide by. Not a 0% delivery failure.
            - `feed_unavailable` — fewer than `min_feed_polls` polls arrived; nothing was measured.
            - `unknown` — the producer sent no status.
        service_delivery_pct:
          type: number
          format: double
          nullable: true
          description: |
            `observed_buses` over `scheduled_buses`. `null` whenever the feed
            cannot support it — `not_reporting`, `low_coverage`,
            `not_scheduled` or `feed_unavailable`. Never `0` as a stand-in.
        scheduled_buses:
          type: integer
          nullable: true
          description: |
            Distinct GTFS `block_id`s with a trip on this route covering the
            current instant. A block is one vehicle's run of work, so this counts
            **buses, not trips**.
        scheduled_buses_all_routes:
          type: integer
          nullable: true
          description: System roll-up only; `null` on per-route rows.
        scheduled_buses_excluded:
          type: integer
          nullable: true
          description: System roll-up only; `null` on per-route rows.
        scheduled_trips_in_progress:
          type: integer
          nullable: true
          description: Trips in progress, reported beside `scheduled_buses` so the trips-vs-vehicles distinction is visible.
        open_ended_trips_excluded:
          type: integer
          description: |
            Trips at the tail of a block with no end bound in the data, excluded
            once departed. This UNDERSTATES `scheduled_buses` and errs toward
            "well served".
        blocks_shared_with_other_routes:
          type: integer
          description: Blocks whose work spans more than this route, so the same vehicle is counted for each.
        observed_buses:
          type: integer
          nullable: true
          description: |
            Median across the window's polls of the distinct vehicles reporting
            this route in a single poll — not the distinct count over the whole
            window, which would show six buses where two shifts of three ran.
            **`null`, never `0`, when the route reported nothing.**
        observed_buses_min:
          type: integer
          nullable: true
          description: Lowest single-poll count in the window. `null` when the route reported nothing.
        observed_buses_max:
          type: integer
          nullable: true
          description: Highest single-poll count in the window. `null` when the route reported nothing.
        observed_vehicles_distinct:
          type: integer
          description: |
            Distinct vehicles across the whole window. A count, so `0` is
            truthful — it says the feed carried no vehicle for this route, which
            is exactly why `observed_buses` is `null`.
        polls_reporting:
          type: integer
          description: Polls in which this route appeared at all.
        poll_coverage_pct:
          type: number
          format: double
          nullable: true
          description: |
            `polls_reporting` as a percentage of `feed_polls`. Report it beside
            both counts: it is the evidence for whether a shortfall is a service
            gap or a reporting gap.
    RailArrival:
      type: object
      description: |
        One rail poll reading: a train's next-arrival prediction at one station,
        as reported by a single poll. NOT one train — the feed reports every
        train against every station ahead of it roughly every 60 seconds, so the
        same train appears many times in a window. `GET /v1/rail/positions`
        collapses that history to one row per train-at-station-direction.

        **This record is deliberately narrow.** It carries the identity of the
        reading and its delay, and nothing else. `observed_at`, `direction`,
        `destination`, `latitude`, `longitude`, `waiting_seconds`,
        `waiting_time`, `event_time`, `next_arrival_text` and `is_realtime` are
        NOT returned here and never have been — this document listed them for
        months while the service emitted six fields. They live on `RailPosition`
        (`GET /v1/rail/positions`), which is the record that carries the full
        row; a client that needs a position or a countdown wants that endpoint,
        not this one.

        Every field is `required`, so every one is present on every record. The
        nullable ones serialize as an explicit `null` rather than vanishing: a
        reading that was not taken is a fact worth transmitting, and an absent
        key is indistinguishable from a client-side default.
      required: [id, line, station, train_id, delay_seconds, delay_status]
      properties:
        id:
          type: integer
          nullable: true
          description: Row id of this observation in `rail_realtime_observations`.
        line:
          type: string
          nullable: true
        station:
          type: string
          nullable: true
          description: The realtime feed's spelling of the station (shout-case, e.g. `FIVE POINTS STATION`).
        train_id:
          type: string
          nullable: true
          description: >-
            `null` for a schedule-text row, which names no physical train. MARTA
            recycles a small pool of numbers per line, so this is never a durable
            trip identifier.
        delay_seconds:
          type: integer
          nullable: true
          description: >-
            Delay reading for this observation. `null` — never `0` — when no
            reading was taken. Around half of observations carry none.
        delay_status:
          type: string
          enum: [unknown, normal, minor, major, severe]
          description: |
            ATL Transit Live delay bucket. Minor is 5+ min, major is 10+ min,
            severe is 20+ min.

            `unknown` means `delay_seconds` is `null` — no reading was taken for
            this arrival, so its punctuality is not known. Do not treat `unknown`
            as `normal`: an arrival that was never measured is not an arrival
            that is on time. A genuine reading of `0` seconds is `normal`.

            **Required**, and not nullable: the server computes it on every
            record from `delay_seconds`, so there is no such thing as an arrival
            without one. Leaving it optional generated
            `*RailArrivalDelayStatus` with `omitempty`, which let a client model
            the one field that distinguishes "unmeasured" from "on time" as a
            value that might not arrive — and a nil enum pointer reads as a
            default, which is the ambiguity `unknown` exists to remove.
    VehicleRealtimeResponse:
      type: object
      required: [mode, route_id, count, page_info, route_vehicle_summary, data_sources, vehicles]
      properties:
        mode:
          type: string
          enum: [bus, streetcar]
        route_id:
          type: string
          nullable: true
          description: Route filter applied to this response, if any.
        count:
          type: integer
          description: Number of vehicles returned in this page.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        route_vehicle_summary:
          type: array
          description: ATL Transit Live computed count and freshness by route.
          items:
            type: object
            required: [route_id, vehicle_count, latest_timestamp]
            properties:
              route_id:
                type: string
              vehicle_count:
                type: integer
              latest_timestamp:
                type: string
                format: date-time
                nullable: true
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        vehicles:
          type: array
          items:
            $ref: '#/components/schemas/VehiclePosition'
    VehiclePosition:
      type: object
      required: [vehicle_id, trip_id, route_id, position, status]
      properties:
        vehicle_id:
          type: string
          nullable: true
        trip_id:
          type: string
          nullable: true
        route_id:
          type: string
          nullable: true
        position:
          type: object
          required: [longitude, latitude, bearing, speed]
          properties:
            longitude:
              type: number
              nullable: true
            latitude:
              type: number
              nullable: true
            bearing:
              type: number
              nullable: true
            speed:
              type: number
              nullable: true
        status:
          type: object
          required: [current_stop_sequence, current_status, timestamp]
          properties:
            current_stop_sequence:
              type: integer
              nullable: true
            current_status:
              type: string
              nullable: true
            timestamp:
              type: string
              format: date-time
              nullable: true
    AffectedEntity:
      type: object
      required: [id, display_name, bluesky_handle, type]
      description: >-
        A MARTA entity (rail line, bus route, station) affected by an alert,
        resolved from the entity registry via the alert's affected_ids. Every
        key is always present; an entity the registry could not resolve a field
        for reports that field as `null` rather than omitting it.
      properties:
        id:
          type: string
        display_name:
          type: string
          nullable: true
        bluesky_handle:
          type: string
          nullable: true
        type:
          type: string
          nullable: true
    AlertFilters:
      type: object
      required: [status, mode, route_or_line, station, station_expanded, affected_id, impact_type, source, q, start, end]
      description: >-
        Echoes what was ASKED FOR, never what the query was rewritten to. The one
        place those differ is the station filter, and `station_expanded` says so
        explicitly rather than leaving a consumer to infer it from a row count.
      properties:
        status:
          type: string
          enum: [active, resolved, all]
        mode:
          type: string
          nullable: true
        route_or_line:
          type: string
          nullable: true
        station:
          type: string
          nullable: true
        station_expanded:
          type: array
          nullable: true
          items:
            type: string
          description: >-
            Every spelling the station filter actually queried. Null when no station
            filter was given, or when the value named no known station and so was
            passed through untouched -- null therefore means "nothing was widened".
        affected_id:
          type: string
          nullable: true
        impact_type:
          type: string
          nullable: true
        source:
          type: string
          nullable: true
        q:
          type: string
          nullable: true
        start:
          type: string
          format: date
          nullable: true
        end:
          type: string
          format: date
          nullable: true
    AlertsResponse:
      type: object
      required: [status, mode, source, count, page_info, data_sources, mode_counts, cross_mode_note, total_note, filters, alerts]
      properties:
        status:
          type: string
          enum: [active, resolved, all]
          description: The status window this page covers. `all` is the union of active and resolved in one request.
        mode:
          nullable: true
          description: The mode this page is scoped to, or null on an unfiltered cross-mode request.
          allOf:
            - $ref: '#/components/schemas/AlertMode'
        source:
          type: string
          enum: [generated_service_alerts]
        count:
          type: integer
          description: Number of alerts returned in this page.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        mode_counts:
          type: object
          nullable: true
          additionalProperties:
            type: integer
          description: >-
            How many alerts IN THIS PAGE belong to each mode. Present only on the
            cross-mode `GET /v1/alerts`; null on the per-mode lists, where the mode
            is already fixed by the path. These are page counts, not totals.
        total_note:
          type: string
          nullable: true
          description: >-
            Non-null exactly when `page_info.total_available` is a LOWER BOUND
            rather than a total. Merging the email and realtime pipelines (an
            email alert suppresses the realtime alert for the same route) happens
            in the API over a bounded window of rows from each source; when a
            window fills, the total counts what was merged, not what matched, and
            for rail that gap is orders of magnitude. `?source=email` and
            `?source=marta_rt` are counted in the database and always report a
            true total. Null means the total is exact.
        cross_mode_note:
          type: string
          nullable: true
          description: >-
            The caveat that governs a mixed-mode page, carried in the payload and
            not only in these docs, because an API consumer has no UI in which to
            read a footnote. Null on the per-mode lists. It says: alert counts are
            NOT comparable between modes -- the rail network is four lines while the
            bus network is roughly eighty routes, so a count is a volume and not a
            rate; only rail realtime rows carry a station, so `station=`
            structurally excludes bus and streetcar; and the email-sourced arm
            reflects which modes MARTA writes alerts about, so a mode's silence is
            not evidence of good service.
        filters:
          $ref: '#/components/schemas/AlertFilters'
        alerts:
          type: array
          items:
            $ref: '#/components/schemas/ServiceAlert'
    AlertMode:
      type: string
      enum: [rail, bus, streetcar, facility]
    RailAlertStatsResponse:
      type: object
      required: [group_by, start, end, range_defaulted, source, counted, counter_definitions, truncation_note, group_count, groups, data_sources]
      properties:
        group_by:
          type: string
          enum: [line, station]
        start:
          type: string
          format: date
          description: Inclusive first America/New_York day counted.
        end:
          type: string
          format: date
          description: Inclusive last America/New_York day counted.
        range_defaulted:
          type: boolean
          description: >-
            True when neither `start` nor `end` was supplied and the last 30 Eastern
            days (ending today) were used. The `start`/`end` above are always the
            days actually counted, defaulted or not.
        source:
          type: string
          enum: [email_process_tracker_v3]
        counted:
          type: string
          description: >-
            Exactly which alerts these counters cover, in the payload rather than
            only in the docs. Email-sourced alerts only, matched on the entity
            registry id in `affected_ids`; the realtime delay state machine is
            excluded and cannot be included, because it carries no `affected_ids`.
        counter_definitions:
          type: object
          additionalProperties:
            type: string
          description: >-
            What each counter means, including the fact that `total` is NOT the sum
            of the others -- one alert can carry several impact types, and an alert
            with none carries none.
        truncation_note:
          type: string
          description: >-
            States that this aggregate is never truncated to a plan limit, so a
            reader can trust `group_count` to be the whole closed set.
        group_count:
          type: integer
          description: "Number of groups returned. Always the whole set: 4 lines or 38 stations."
        groups:
          type: array
          items:
            $ref: '#/components/schemas/RailAlertStatsGroup'
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    RailAlertStatsGroup:
      type: object
      required: [id, slug, name, feed_name, total, cancellations, delays, mechanical, facility]
      properties:
        id:
          type: string
          description: The `marta_entities_v3` id these counters were matched on, e.g. RAIL_SERVICE_RED or RAIL_STATION_FIVEPOINTS.
        slug:
          type: string
          description: URL segment for this line or station on the public site.
        name:
          type: string
          description: >-
            Display name from the entity registry, falling back to the entity id
            when the registry has no row for it -- the group is never dropped, so a
            closed set stays closed.
        total:
          type: integer
          description: Alerts naming this entity in the range. Not the sum of the impact counters.
        cancellations:
          type: integer
          description: Alerts whose `impact_type` includes `cancellation`.
        delays:
          type: integer
          description: Alerts whose `impact_type` includes `delay`.
        mechanical:
          type: integer
          description: Alerts whose `impact_type` includes `mechanical`.
        facility:
          type: integer
          description: >-
            Alerts whose alert TYPE is `station_facility` -- elevator and escalator
            outages. This is a type, not an entry in `impact_type`, which is why it
            is counted separately. On a line group it counts facility alerts that
            named the line itself, which is normally zero.
        feed_name:
          type: string
          nullable: true
          description: >-
            For station groups, the spelling the realtime feed uses for this station,
            which differs from `name` (the entity spelling) and is what alert rows
            store. Null for line groups. Both spellings are accepted by
            `GET /v1/rail/alerts?station=`, which expands whichever you give it.
    ServiceAlert:
      type: object
      required: [id, incident_id, mode, status, affected, impact, message, timing, structured_alert, source]
      properties:
        id:
          type: string
        incident_id:
          type: string
          nullable: true
        mode:
          type: string
          enum: [bus, rail, streetcar, facility, unknown]
        status:
          type: string
          enum: [active, resolved, ongoing, other]
        affected:
          type: object
          description: Normalized route, line, station, trip, train, and source affected IDs.
          required: [route_or_line, station, trip_or_train_id, affected_ids, affected_entities, impacted_lines]
          properties:
            route_or_line:
              type: string
              nullable: true
            station:
              type: string
              nullable: true
            trip_or_train_id:
              type: string
              nullable: true
            affected_ids:
              type: array
              items:
                type: string
            affected_entities:
              type: array
              description: Affected entities resolved from affected_ids against the entity registry (id, display_name, bluesky_handle, type).
              items:
                $ref: '#/components/schemas/AffectedEntity'
            impacted_lines:
              type: array
              items:
                type: string
        impact:
          type: object
          description: Impact facts. LLM-derived source alerts preserve array-valued `impact_type`; generated delay alerts expose delay seconds and minutes.
          required: [impact_types, delay_seconds, delay_minutes, impacts]
          properties:
            impact_types:
              type: array
              items:
                type: string
            delay_seconds:
              type: integer
              nullable: true
            delay_minutes:
              type: integer
              nullable: true
            impacts:
              type: array
              items:
                $ref: '#/components/schemas/BusTripImpact'
        message:
          type: object
          required: [title, body, microblog_messages]
          properties:
            title:
              type: string
              nullable: true
            body:
              type: string
              nullable: true
            microblog_messages:
              type: array
              description: Array-valued because MARTA source alerts can be split into multiple microblog-safe messages.
              items:
                type: string
        timing:
          type: object
          required: [timestamp, published_at, suppressed_until, created_at, updated_at]
          properties:
            published_at:
              type: string
              format: date-time
              nullable: true
            suppressed_until:
              type: string
              format: date-time
              nullable: true
            created_at:
              type: string
              format: date-time
              nullable: true
            updated_at:
              type: string
              format: date-time
              nullable: true
            timestamp:
              type: string
              format: date-time
              nullable: true
        structured_alert:
          nullable: true
          oneOf:
            - $ref: '#/components/schemas/BusStructuredAlert'
            - $ref: '#/components/schemas/RailStructuredAlert'
            - $ref: '#/components/schemas/StreetcarStructuredAlert'
            - $ref: '#/components/schemas/RailFacilityStructuredAlert'
          description: >-
            The stored LLM-derived structured JSON for source alerts, or `null`
            for generated (realtime-derived) alerts. The key is ALWAYS present.

            **This value is passed through verbatim.** Unlike the rest of this
            response, the API does not build it field by field — it is stored
            JSON, relayed as-is. The four schemas below therefore describe the
            shape the extraction targets, not a shape the API enforces: a
            property that is `nullable` and absent from its `required` list may
            be `null` OR missing from the object entirely, and a consumer must
            handle both. Everything outside `structured_alert` is constructed by
            this service and always emits every documented key.
        source:
          type: object
          required: [kind, upstream_source]
          properties:
            kind:
              type: string
              enum: [generated_service_alert, llm_structured_service_alert]
            upstream_source:
              type: string
              nullable: true
    BusStructuredAlert:
      type: object
      required: [type, microblog_message, affected_ids, route_number, route_name, impact_type, impacts, timestamp]
      properties:
        type:
          enum: [bus_service]
        microblog_message:
          type: array
          items:
            type: string
        affected_ids:
          type: array
          items:
            type: string
        route_number:
          type: string
        route_name:
          type: string
        impact_type:
          type: array
          items:
            type: string
            enum: [cancellation, delay, detour, other, medical_emergency, police_activity]
        impacts:
          type: array
          items:
            $ref: '#/components/schemas/BusTripImpact'
        timestamp:
          type: string
          format: date-time
    BusTripImpact:
      type: object
      description: >-
        One trip-level impact inside `impact.impacts`. Like `structured_alert`,
        this array is stored JSON relayed verbatim rather than built by the API,
        so every property here may be `null` OR absent entirely. The array
        itself is always present, defaulting to `[]`.
      properties:
        direction:
          type: string
          nullable: true
        origin:
          type: string
          nullable: true
        destination:
          type: string
          nullable: true
        departure_time:
          type: string
          nullable: true
          format: date-time
    RailStructuredAlert:
      type: object
      required: [type, microblog_message, affected_ids, impacted_lines, impact_type, location, status, timestamp]
      properties:
        type:
          enum: [rail_service]
        microblog_message:
          type: array
          items:
            type: string
        affected_ids:
          type: array
          items:
            type: string
        impacted_lines:
          type: array
          items:
            type: string
            enum: [Red, Gold, Blue, Green]
        direction:
          type: string
          nullable: true
          enum: [NB, SB, EB, WB]
        impact_type:
          type: array
          items:
            type: string
            enum: [delay, cancellation, single_tracking, emergency_response, mechanical, police_activity, medical_emergency, other]
        location:
          $ref: '#/components/schemas/RailAlertLocation'
        status:
          type: string
          enum: [ongoing, resolved, other]
        timestamp:
          type: string
          format: date-time
    RailAlertLocation:
      type: object
      required: [station]
      properties:
        station:
          type: string
        platform:
          type: string
          nullable: true
    StreetcarStructuredAlert:
      type: object
      required: [type, microblog_message, affected_ids, impact_type, status, timestamp]
      properties:
        type:
          enum: [streetcar_service]
        microblog_message:
          type: array
          items:
            type: string
        affected_ids:
          type: array
          items:
            type: string
        impact_type:
          type: array
          items:
            type: string
            enum: [delay, cancellation, modified_service, emergency_response, mechanical, other]
        location:
          type: string
          nullable: true
        status:
          type: string
          enum: [ongoing, resolved, other]
        timestamp:
          type: string
          format: date-time
    RailFacilityStructuredAlert:
      type: object
      required: [type, microblog_message, affected_ids, station, facility, status, reason, timestamp]
      properties:
        type:
          enum: [station_facility]
        microblog_message:
          type: array
          items:
            type: string
        affected_ids:
          type: array
          items:
            type: string
        station:
          type: string
        facility:
          $ref: '#/components/schemas/RailFacility'
        status:
          type: string
          enum: [out_of_service, back_in_service, limited_service, other]
        reason:
          type: string
          enum: [renovation, repair, maintenance, construction, unknown, other]
        alternate_access:
          type: string
          nullable: true
        timestamp:
          type: string
          format: date-time
    RailFacility:
      type: object
      required: [type, identifier]
      properties:
        type:
          type: string
          enum: [elevator, escalator, restroom, other]
        identifier:
          type: string
        location:
          type: string
          nullable: true
    LineFeedbackResponse:
      type: object
      description: >-
        Aggregate rider feedback for one line. Returned by both the read and the
        write, so a client can render the updated state without a second call.
      required: [line_id, average_rating, rating_count, comments]
      properties:
        line_id:
          type: string
          description: The canonical entity id the supplied `id` resolved to.
        average_rating:
          type: number
          format: double
          nullable: true
          description: >-
            Mean rating rounded to one decimal place. `null` — never `0` — when
            there are no ratings: a line nobody has rated is unrated, not rated
            zero, and `0` is not even in the 1-5 range a rating can take.
        rating_count:
          type: integer
          format: int64
          description: Ratings behind `average_rating`. A count, so `0` is truthful.
        comments:
          type: array
          description: >-
            Up to 10 moderator-APPROVED comments, most recent first. Comments
            awaiting moderation are never included, so a comment you just
            submitted will not be here.
          items:
            $ref: '#/components/schemas/LineComment'
    LineComment:
      type: object
      required: [name, body, rating, created_at]
      properties:
        name:
          type: string
          description: Display name, or `Anonymous rider` when none was supplied.
        body:
          type: string
        rating:
          type: integer
          nullable: true
          description: Rating submitted alongside the comment, 1-5; `null` if none.
        created_at:
          type: string
          format: date-time
          description: RFC 3339 UTC timestamp.
    LineFeedbackSubmission:
      type: object
      required: [rating]
      properties:
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: >-
            Whole number from 1 to 5. Required even when you are only leaving a
            comment. An UPSERT: submitting again replaces your previous rating
            for this line rather than adding another.
        name:
          type: string
          description: Optional display name. Omitted or blank shows as `Anonymous rider`.
        comment:
          type: string
          description: >-
            Optional. Stored `pending` for out-of-band moderation and NOT
            included in the response; only approved comments are ever returned.
        submission_id:
          type: string
          format: uuid
          description: >-
            Optional client-generated UUID that makes a retry idempotent — a
            repeat with the same id is recognised as a replay, stored once, and
            exempt from the comment throttle. Omit it and the server generates
            one per request, which makes a retried request a second comment.
    RailStationsResponse:
      type: object
      description: >-
        The rail station directory. `count` is the size of the whole set, not of
        a page: this endpoint is never truncated.
      required: [mode, count, note, data_sources, stations]
      properties:
        mode:
          enum: [rail]
        count:
          type: integer
          description: Stations returned, which is every station observed in the feed.
        note:
          type: string
          description: >-
            Carries the id conventions in the payload rather than only in these
            docs, because an API consumer has no UI in which to read a footnote.
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        stations:
          type: array
          items:
            $ref: '#/components/schemas/RailStation'
    RailStation:
      type: object
      required: [station, lines]
      properties:
        station:
          type: string
          description: >-
            The realtime feed's spelling, e.g. `FIVE POINTS STATION`. This is the
            value the `station=` filters match on.
        lines:
          type: array
          description: >-
            Lines observed serving this station. Empty rather than absent when
            none were observed.
          items:
            type: string
            enum: [RED, GOLD, BLUE, GREEN]
    ModeRoutesResponse:
      type: object
      required: [mode, count, routes, day_periods, days, last_updated, links, data_sources]
      properties:
        mode:
          $ref: '#/components/schemas/Mode'
        count:
          type: integer
        routes:
          type: array
          items:
            $ref: '#/components/schemas/Route'
        day_periods:
          type: object
          nullable: true
          additionalProperties:
            type: array
            items:
              type: integer
        days:
          type: integer
        last_updated:
          type: string
          format: date-time
          nullable: true
        links:
          type: object
          description: >-
            Related mode-first endpoints, as site-relative paths in the PUBLIC
            `/api/v1/...` spelling — e.g. `/api/v1/rail/realtime` or
            `/api/v1/bus/{route_id}/reliability`. Resolve them against the origin
            you are calling. Values whose key ends `_template` carry a
            `{placeholder}` to substitute; the rest are complete paths.
          additionalProperties:
            type: string
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    Route:
      type: object
      required: [route_id, route_short_name, route_long_name, mode, route_type, reliability]
      properties:
        route_id:
          type: string
          nullable: true
        mode:
          $ref: '#/components/schemas/Mode'
        route_short_name:
          type: string
          nullable: true
        route_long_name:
          type: string
          nullable: true
        route_type:
          type: integer
          nullable: true
          description: GTFS `route_type` — 1 rail, 3 bus, 0 streetcar.
        reliability:
          type: object
          description: |
            The route's reliability snapshot over the `days` window.

            **Rail and streetcar have no cancellation signal**, so a
            cancellation-derived `reliability_pct` for those modes is an artifact
            of an empty feed rather than a measurement — see
            `RouteReliabilityResponse`. Do not rank rail and bus routes together
            on this field.
          required: [reliability_pct, scheduled_trips, cancelled_trips, latest_week]
          properties:
            reliability_pct:
              type: number
              nullable: true
              description: '`null` — never `0` and never `100` — when no trips back the window.'
            scheduled_trips:
              type: integer
              nullable: true
            cancelled_trips:
              type: integer
              nullable: true
            latest_week:
              type: string
              format: date
              nullable: true
    RouteReliabilityResponse:
      type: object
      description: |
        Reliability rollup for one route. Shared by the rail, bus, and
        streetcar reliability endpoints.

        **Rail reliability and bus reliability are different metrics and must
        never be silently compared.** Bus and streetcar reliability is
        scheduled-versus-cancelled trips. Rail has no usable cancellation
        signal — the cancellation feed has never contained a rail trip — so a
        cancellation-based rail figure is always exactly `100.0`, an artifact
        of missing data rather than a measurement. Rail reliability is instead
        expressed as on-time performance: the share of realtime observations
        whose delay reading falls under the on-time threshold, measured only
        over the observations that carried a reading. Do not chart a rail
        `reliability_pct` on the same axis as a bus one, rank the two together,
        or aggregate them into a single system figure.
      required: [route_id, breakdown, period, range, count, records, period_definitions, data_sources]
      properties:
        route_id:
          type: string
        breakdown:
          type: string
          enum: [day, week, period]
        period:
          type: string
          nullable: true
        range:
          type: object
          required: [days, weeks, start_date, end_date]
          properties:
            days:
              type: integer
              nullable: true
            weeks:
              type: integer
              nullable: true
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
        count:
          type: integer
        records:
          type: array
          items:
            $ref: '#/components/schemas/RouteReliabilityBreakdown'
        period_definitions:
          type: object
          nullable: true
          additionalProperties:
            type: array
            items:
              type: integer
        data_sources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
    RouteReliabilityBreakdown:
      type: object
      description: |
        One reliability row.

        `date` carries a value on day breakdowns, `week_start` on weekly ones and
        `day_period` on period ones; the ones that do not apply to the breakdown
        you asked for are `null`. They are still `required`, and that is the
        point: the server emits every key on every row, so a `null` here is the
        row telling you the field does not apply. Leaving them optional generated
        `omitempty` pointers, which made an inapplicable field and an unmeasured
        one both disappear — and `reliability_pct`, whose whole contract is that
        it is `null` rather than `0` or `100` when nothing backs it, was among
        them.
      required:
        - route_id
        - route_short_name
        - route_long_name
        - date
        - week_start
        - day_period
        - breakdown
        - scheduled_trips
        - cancelled_trips
        - reliability_pct
        - data_source
        - calculated_at
      properties:
        route_id:
          type: string
          nullable: true
        route_short_name:
          type: string
          nullable: true
        route_long_name:
          type: string
          nullable: true
        date:
          type: string
          format: date
          nullable: true
        week_start:
          type: string
          format: date
          nullable: true
        day_period:
          type: string
          nullable: true
        breakdown:
          type: string
          enum: [day, week, period]
        scheduled_trips:
          type: integer
          nullable: true
        cancelled_trips:
          type: integer
          nullable: true
        reliability_pct:
          type: number
          nullable: true
          description: |
            `null` when the window carries no data to support a figure — never
            `0` and never `100`. Note that this field does not mean the same
            thing across modes: for bus and streetcar it is
            scheduled-versus-cancelled trips, while rail has no cancellation
            signal and reports on-time performance instead. See the
            `RouteReliabilityResponse` description; the two must never be
            silently compared.
        data_source:
          type: string
          nullable: true
          description: Observed values include `wmb`, `email`, and `realtime`.
        calculated_at:
          type: string
          format: date-time
          nullable: true
        unmatched_cancellations:
          type: array
          items:
            type: object
            additionalProperties: true
    HistoryExportsResponse:
      type: object
      required: [dataset, count, page_info, exports]
      properties:
        dataset:
          type: string
          nullable: true
          description: The `dataset` filter that was applied, or `null` when none was given.
        count:
          type: integer
          description: Manifests in THIS page.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        exports:
          type: array
          items:
            $ref: '#/components/schemas/HistoryExport'
    HistoryExport:
      type: object
      description: |
        One export manifest. Every field is `required`, so the nullable ones
        arrive as an explicit `null`: a pending export genuinely has no
        `row_count` yet, and that is a different statement from the field being
        absent.
      required:
        - id
        - dataset
        - format
        - status
        - download_path
        - row_count
        - byte_size
        - checksum_sha256
        - start_date
        - end_date
        - created_at
        - expires_at
        - shared
        - last_error
      properties:
        id:
          type: string
        dataset:
          type: string
        format:
          type: string
          enum: [ndjson.gz, parquet]
          description: >-
            The artifact's encoding, chosen when the export was created and fixed
            for its lifetime. `ndjson.gz` is the default and is available on every
            plan; `parquet` requires the Max plan. The download serves this
            format's media type and a matching filename -- an export is never
            transcoded on the way out, so this field always describes the bytes
            you will actually receive.
        status:
          type: string
          enum: [pending, available, failed, expired]
        download_path:
          type: string
          description: >-
            Site-relative path to the authenticated, reauthorized download
            endpoint, in the PUBLIC `/api/v1/...` spelling — resolve it against
            the origin you are calling and follow it with the same auth (bearer
            key or session cookie) you used to list. It is not the object's
            location: the endpoint reauthorizes and then 302s to a signed URL
            valid for five minutes.
        row_count:
          type: integer
          nullable: true
          description: '`null` until the artifact has been written; never `0` as a placeholder.'
        byte_size:
          type: integer
          nullable: true
          description: '`null` until the artifact has been written.'
        checksum_sha256:
          type: string
          nullable: true
          description: SHA-256 of the artifact. `null` until it has been written.
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        created_at:
          type: string
          format: date-time
          description: When the manifest row was reserved (RFC 3339 UTC).
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: '`null` while the export is pending — nothing has been stored to expire yet.'
        shared:
          type: boolean
          description: True for the free canonical daily export every Max customer sees; false for a private on-demand export the caller generated. The raw owner id is never exposed.
        last_error:
          type: string
          nullable: true
          description: Set only on the caller's own 'failed' rows. Other customers' and the shared job's pending/failed rows are never listed, so this is always safe to show as-is.
    HistoryExportGenerateRequest:
      type: object
      required: [dataset]
      properties:
        dataset:
          type: string
          enum: [reliability, realtime, alerts]
        format:
          type: string
          enum: [ndjson.gz, parquet]
          default: ndjson.gz
          description: >-
            Artifact encoding to generate. `parquet` requires the **Max** plan and
            is refused with `403 feature_not_in_plan` otherwise -- it is never
            quietly downgraded to `ndjson.gz`. The same dataset and window in two
            formats are two separate exports, each with its own manifest.
        days:
          type: integer
          minimum: 1
          description: Preset window -- N inclusive days ending today (America/New_York). Mutually exclusive with start_date/end_date.
        start_date:
          type: string
          format: date
          description: Inclusive range start (America/New_York). Requires end_date.
        end_date:
          type: string
          format: date
          description: Inclusive range end (America/New_York). Requires start_date.
    HistoryExportGenerateResponse:
      type: object
      required: [export]
      properties:
        export:
          $ref: '#/components/schemas/HistoryExport'
    ApiEvent:
      type: object
      required: [id, type, api_version, created_at, resource, data]
      properties:
        id:
          type: string
        type:
          $ref: '#/components/schemas/WebhookEventType'
        api_version:
          enum: [v1]
        created_at:
          type: string
          format: date-time
        resource:
          type: string
        data:
          type: object
    WebhookEventType:
      type: string
      description: Event taxonomy used by both SSE events and webhook deliveries for refreshed resources.
      enum:
        - rail.alert.created
        - rail.alert.updated
        - rail.alert.resolved
        - rail.realtime.updated
        - rail.reliability.updated
        - bus.alert.created
        - bus.alert.updated
        - bus.alert.resolved
        - bus.realtime.updated
        - bus.reliability.updated
        - streetcar.alert.created
        - streetcar.alert.updated
        - streetcar.alert.resolved
        - streetcar.realtime.updated
        - streetcar.reliability.updated
        - history.export.ready
    WebhookEndpointCreate:
      type: object
      required: [url, event_types]
      properties:
        url:
          type: string
          format: uri
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        filters:
          type: object
          properties:
            mode:
              type: array
              items:
                $ref: '#/components/schemas/Mode'
            route_or_line:
              type: array
              items:
                type: string
    WebhookEndpoint:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpointCreate'
        - type: object
          required:
            - id
            - status
            - signing_secret_prefix
            - failure_count
            - last_success_at
            - last_failure_at
            - created_at
            - updated_at
          properties:
            id:
              type: string
              format: uuid
            status:
              type: string
              enum: [active, paused, disabled, disabled_by_plan]
              description: >-
                `disabled_by_plan` is a reversible state: the endpoint exceeded
                the current plan's webhook limit after a downgrade and is parked
                (no deliveries) until the plan is upgraded, when it is restored
                to its prior active/paused state. It cannot be edited or resumed
                while parked — only deleted.
            signing_secret_prefix:
              type: string
              description: >-
                The leading bytes of the signing secret, enough to identify which
                secret an endpoint is currently using. The secret itself is
                returned exactly twice — at creation and on a rotation — and is
                never readable afterwards.
            failure_count:
              type: integer
              description: Consecutive delivery failures. A count, so `0` is truthful.
            last_success_at:
              type: string
              format: date-time
              nullable: true
              description: '`null` when this endpoint has never taken a successful delivery.'
            last_failure_at:
              type: string
              format: date-time
              nullable: true
              description: '`null` when this endpoint has never failed a delivery.'
            created_at:
              type: string
              format: date-time
            updated_at:
              type: string
              format: date-time
    WebhookEndpointEnvelope:
      type: object
      description: |
        The body returned by endpoint CREATE and UPDATE. The endpoint is nested
        under `endpoint`, not spread at the top level, so that `signing_secret`
        has somewhere to sit that is unambiguously NOT part of the endpoint
        record — it is a one-time reveal, returned on creation and on a rotation
        and never again, and it must not look like a field a subsequent read
        will hand back.
      required: [endpoint]
      properties:
        endpoint:
          $ref: '#/components/schemas/WebhookEndpoint'
        signing_secret:
          type: string
          description: >-
            The full signing secret (`mfwhsec_…`), present ONLY on the response
            that minted it: creation, or an update with `rotate_secret: true`.
            Store it now; no later read returns it. Absent on an update that did
            not rotate.
    WebhookEndpointsResponse:
      type: object
      required: [endpoints]
      properties:
        endpoints:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEndpoint'
    WebhookEndpointUpdate:
      type: object
      properties:
        url:
          type: string
        event_types:
          type: array
          items:
            type: string
        filters:
          type: object
          properties:
            mode:
              type: array
              items:
                type: string
            route_or_line:
              type: array
              items:
                type: string
        status:
          type: string
          enum: [active, paused]
        rotate_secret:
          type: boolean
    WebhookDeliveriesResponse:
      type: object
      required: [count, page_info, deliveries]
      properties:
        count:
          type: integer
          description: Delivery records in THIS page.
        page_info:
          $ref: '#/components/schemas/PageInfo'
        deliveries:
          type: array
          items:
            $ref: '#/components/schemas/WebhookDelivery'
    WebhookDelivery:
      type: object
      description: |
        One delivery attempt record. Every field is `required`, so the nullable
        ones arrive as an explicit `null` rather than vanishing — "not yet
        delivered" and "field omitted" are different statements about a delivery,
        and a client should not have to tell them apart by key presence.
      required:
        - id
        - endpoint_id
        - event_id
        - event_type
        - status
        - attempt_count
        - next_attempt_at
        - delivered_at
        - last_attempt_at
        - last_response_status
        - last_error
        - billable
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        endpoint_id:
          type: string
          format: uuid
        event_id:
          type: string
        event_type:
          $ref: '#/components/schemas/WebhookEventType'
        status:
          type: string
          enum: [pending, delivered, retrying, failed]
        attempt_count:
          type: integer
          description: Attempts made so far. A count, so `0` is truthful.
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: '`null` when no further attempt is scheduled (delivered, or permanently failed).'
        delivered_at:
          type: string
          format: date-time
          nullable: true
          description: '`null` until a delivery succeeds.'
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: '`null` before the first attempt.'
        last_response_status:
          type: integer
          nullable: true
          description: >-
            HTTP status of the last attempt. `null` — never `0` — when the
            attempt never got a response at all (DNS failure, connection refused,
            timeout), which is a different outcome from a rejection.
        last_error:
          type: string
          nullable: true
          description: '`null` when the last attempt did not fail.'
        billable:
          type: boolean
          description: Whether this delivery counted against the `webhook_event` meter.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
