openapi: 3.1.0
info:
  title: Travelmode Weather Intelligence API
  version: 1.0.0
  summary: Activity-aware weather, timeline analysis, route weather, and weather watches.
  description: |
    The Weather Intelligence API exposes Travelmode's normalized weather
    snapshots, activity-aware risk scoring, timeline analysis, route
    weather, and weather watches under a single versioned `/v1`
    surface.

    All endpoints share the same response envelope, error format, API
    key authentication scheme, and rate-limit / cache headers. See the
    accompanying markdown docs in `docs/weather-api/` for narrative
    guidance, billing tables, and worked examples.
  contact:
    name: Travelmode Developer Support
    url: https://travelmode.ai
  license:
    name: Proprietary
    url: https://travelmode.ai

servers:
  - url: https://api.travelmode.ai
    description: Production
  - url: https://{host}
    description: Self-hosted / Replit deployment. Replace `{host}` with your deployed domain.
    variables:
      host:
        default: your-app-domain.replit.app
tags:
  - name: Weather
    description: Layer 1–3 weather endpoints (current, forecast, timeline, route).
  - name: Watches
    description: Layer 4 — register monitored weather targets and read change events.
  - name: Health
    description: Lightweight liveness probe.
security:
  - BearerApiKey: []
  - HeaderApiKey: []
paths:
  /v1/weather/current:
    get:
      tags:
        - Weather
      summary: Current weather + activity-aware risk for a coordinate.
      description: |
        Returns the current weather snapshot for a `(lat, lng)` going
        through the shared geohash cache. Always includes a `risk`
        block; when `activity_type` is omitted the default outdoor
        profile is used.

        **Billing:** 1 unit per successful response. **Required scope:**
        `weather:read`.
      operationId: getCurrentWeather
      parameters:
        - $ref: "#/components/parameters/Lat"
        - $ref: "#/components/parameters/Lng"
        - $ref: "#/components/parameters/Units"
        - $ref: "#/components/parameters/Language"
        - $ref: "#/components/parameters/ActivityType"
      responses:
        "200":
          description: Successful response.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache-Status:
              $ref: "#/components/headers/XCacheStatus"
            X-Weather-Snapshot-Id:
              $ref: "#/components/headers/XSnapshotId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CurrentWeatherEnvelope"
              examples:
                paris:
                  $ref: "#/components/examples/CurrentWeatherParis"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/ProviderUnavailable"
        "504":
          $ref: "#/components/responses/ProviderTimeout"
  /v1/weather/forecast:
    get:
      tags:
        - Weather
      summary: Hourly or daily forecast for a coordinate.
      description: |
        Returns either an hourly or daily forecast for the requested
        coordinate, sliced to the caller's `days` horizon (clamped to
        the provider's maximum of 8 days).

        **Billing:** `granularity=daily` → 1 unit; `granularity=hourly`
        → 2 units. **Required scope:** `weather:read`.
      operationId: getForecast
      parameters:
        - $ref: "#/components/parameters/Lat"
        - $ref: "#/components/parameters/Lng"
        - $ref: "#/components/parameters/Units"
        - $ref: "#/components/parameters/Language"
        - name: days
          in: query
          description: Forecast horizon in days. Clamped to the provider max (8).
          schema:
            type: integer
            minimum: 1
            maximum: 8
            default: 7
        - name: granularity
          in: query
          description: |
            `daily` returns 1 entry per local day; `hourly` returns up
            to `days * 24` hourly entries. Hourly responses are billed
            at 2 units, daily at 1.
          schema:
            type: string
            enum:
              - hourly
              - daily
            default: daily
      responses:
        "200":
          description: Successful response.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache-Status:
              $ref: "#/components/headers/XCacheStatus"
            X-Weather-Snapshot-Id:
              $ref: "#/components/headers/XSnapshotId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForecastEnvelope"
              examples:
                paris-daily:
                  $ref: "#/components/examples/ForecastParisDaily"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/ProviderUnavailable"
        "504":
          $ref: "#/components/responses/ProviderTimeout"
  /v1/weather/timeline:
    post:
      tags:
        - Weather
      summary: Per-item weather + risk for an itinerary timeline.
      description: |
        Accepts up to 100 itinerary items and returns weather plus
        activity-aware risk for each, along with an aggregate `summary`
        (overall risk, main issue, best outdoor window).

        **Billing:** 2 units per item. **Required scope:**
        `weather:timeline`.
      operationId: postTimelineWeather
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TimelineRequest"
            examples:
              two-items:
                $ref: "#/components/examples/TimelineRequestTwoItems"
      responses:
        "200":
          description: Successful response.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache-Status:
              $ref: "#/components/headers/XCacheStatus"
            X-Weather-Snapshot-Id:
              $ref: "#/components/headers/XSnapshotId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TimelineEnvelope"
              examples:
                two-items:
                  $ref: "#/components/examples/TimelineResponseTwoItems"
        "400":
          description: Invalid request body or invalid timeline item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                bad-envelope:
                  value:
                    error:
                      code: bad_request
                      message: Request body did not match the timeline schema.
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                    meta:
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                      generated_at: 2026-04-25T18:34:00.000Z
                bad-item:
                  value:
                    error:
                      code: timeline_item_invalid
                      message: Item at index 0 has an invalid start timestamp.
                      details:
                        index: 0
                        field: start
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                    meta:
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                      generated_at: 2026-04-25T18:34:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/ProviderUnavailable"
        "504":
          $ref: "#/components/responses/ProviderTimeout"
  /v1/weather/route:
    post:
      tags:
        - Weather
      summary: Per-segment weather + risk along a route.
      description: |
        Samples points along the supplied route (encoded polyline,
        point list, or a great-circle line between origin and
        destination) and returns weather + risk per segment, plus an
        `overall_risk` summary.

        **Billing:** 3 units per segment analyzed. **Required scope:**
        `weather:route`.
      operationId: postRouteWeather
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RouteRequest"
            examples:
              car-trip:
                $ref: "#/components/examples/RouteRequestCarTrip"
      responses:
        "200":
          description: Successful response.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache-Status:
              $ref: "#/components/headers/XCacheStatus"
            X-Weather-Snapshot-Id:
              $ref: "#/components/headers/XSnapshotId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RouteEnvelope"
              examples:
                car-trip:
                  $ref: "#/components/examples/RouteResponseCarTrip"
        "400":
          description: Invalid route request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                route-invalid:
                  value:
                    error:
                      code: route_invalid
                      message: Route request is malformed
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                    meta:
                      request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
                      generated_at: 2026-04-25T18:34:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/ProviderUnavailable"
        "504":
          $ref: "#/components/responses/ProviderTimeout"
  /v1/weather/watch:
    post:
      tags:
        - Watches
      summary: Create a weather watch.
      description: |
        Register a place / timeline item / route window / trip for
        ongoing monitoring. Change rules default per PRD section 18.2;
        only fields you supply override the defaults.

        **Billing:** 1 unit on creation. The daily accrual job adds 1
        unit per active day per watch. **Required scope:**
        `weather:watch`.
      operationId: createWatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WatchCreateRequest"
            examples:
              place-watch:
                $ref: "#/components/examples/WatchCreatePlace"
      responses:
        "201":
          description: Watch created.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchCreateEnvelope"
              examples:
                place-watch:
                  $ref: "#/components/examples/WatchCreateResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /v1/weather/watch/{watch_id}:
    parameters:
      - name: watch_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
        description: ID returned by `POST /v1/weather/watch`.
    get:
      tags:
        - Watches
      summary: Fetch a watch with its latest snapshot, risk, and recent change events.
      description: |
        Returns the watch row hydrated with its latest snapshot,
        latest risk assessment, and most-recent change events. Returns
        404 when the watch does not belong to the calling developer
        account.

        **Billing:** 1 unit per call. **Required scope:** `weather:watch`.
      operationId: getWatch
      responses:
        "200":
          description: Watch found.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Weather-Snapshot-Id:
              $ref: "#/components/headers/XSnapshotId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchGetEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags:
        - Watches
      summary: Cancel a weather watch.
      description: |
        Soft-cancels the watch (`status='cancelled'`,
        `expired_at=now`). The refresh job will not pick it up after
        this. Returns 404 if the watch is not owned by the calling
        developer account.

        **Billing:** 1 unit per call. **Required scope:** `weather:watch`.
      operationId: deleteWatch
      responses:
        "200":
          description: Watch cancelled.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchDeleteEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    patch:
      tags:
        - Watches
      summary: Set, update, or clear a watch's webhook URL.
      description: |
        Sets, updates, or clears the watch's per-watch `webhook_url`. Pass a
        valid URL to set/update it, or `webhook_url: null` to clear it
        (per-watch deliveries stop). A non-null URL is validated through the
        same SSRF guard the delivery worker uses, so an unreachable / blocked
        address is rejected with a 400 instead of being silently stored.
        Returns 404 when the watch is not owned by the calling developer
        account.

        **Billing:** 1 unit per call. **Required scope:** `weather:watch`.
      operationId: updateWatchWebhookUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhook_url]
              properties:
                webhook_url:
                  type: [string, "null"]
                  format: uri
                  description: |
                    A valid URL to set/update the watch's webhook, or
                    `null` to clear it.
              additionalProperties: false
            examples:
              set-url:
                value:
                  webhook_url: https://example.com/hooks/weather
              clear-url:
                value:
                  webhook_url: null
      responses:
        "200":
          description: Webhook URL updated.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    required: [watch_id, status, webhook_url, updated_at]
                    properties:
                      watch_id:
                        type: string
                        format: uuid
                      status:
                        type: string
                      webhook_url:
                        type: [string, "null"]
                        format: uri
                      updated_at:
                        type: string
                        format: date-time
                  meta:
                    $ref: "#/components/schemas/ApiMeta"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /v1/weather/watch/{watch_id}/test:
    parameters:
      - name: watch_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
        description: ID returned by `POST /v1/weather/watch`.
    post:
      tags:
        - Watches
      summary: Send a sample webhook delivery to verify receiver wiring.
      description: |
        Delivers a sample `weather.change_detected` event to the watch's
        configured `webhook_url`, reusing the exact signing path, headers,
        and SSRF guard the real per-watch dispatcher uses. This one-shot
        call does NOT persist anything (no change-event row, no
        failure-count bump) — it only reports the delivery outcome so you
        can verify your receiver. Returns 404 when the watch is not owned by
        the calling developer account, and 400 when the watch has no
        `webhook_url` configured.

        **Billing:** 1 unit per call. **Required scope:** `weather:watch`.
      operationId: testWatchWebhook
      responses:
        "200":
          description: Delivery attempt completed (see `delivered`/`status`).
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    required:
                      [watch_id, webhook_url, delivered, status, signed]
                    properties:
                      watch_id:
                        type: string
                        format: uuid
                      webhook_url:
                        type: string
                        format: uri
                      delivered:
                        type: boolean
                      status:
                        type: [integer, "null"]
                        description: HTTP status returned by the receiver.
                      signed:
                        type: boolean
                      error:
                        type: [string, "null"]
                      event_id:
                        type: [string, "null"]
                      event_type:
                        type: [string, "null"]
                  meta:
                    $ref: "#/components/schemas/ApiMeta"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /v1/weather/health:
    get:
      tags:
        - Health
      summary: Liveness probe for the Weather API surface.
      description: |
        Tiny ping endpoint that proves the public API surface is
        reachable and the caller's API key + scope check succeeded.
        Returns a minimal `{ status: "ok" }` payload so health checks
        do not depend on the upstream weather provider being warm.

        **Billing:** 0 units. **Required scope:** `weather:read`.
      operationId: getWeatherHealth
      responses:
        "200":
          description: Surface is healthy.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
components:
  securitySchemes:
    BearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: tm_weather_*****
      description: |
        Send your API key as `Authorization: Bearer tm_weather_...`.
        Keys are minted via internal admin tooling for the MVP; the
        plaintext is shown exactly once on creation. See the
        Authentication doc.
    HeaderApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Alternative to the bearer header. Send the same `tm_weather_*`
        token as the value of `X-API-Key`.
  parameters:
    Lat:
      name: lat
      in: query
      required: true
      description: Latitude in WGS-84 decimal degrees.
      schema:
        type: number
        minimum: -90
        maximum: 90
    Lng:
      name: lng
      in: query
      required: true
      description: Longitude in WGS-84 decimal degrees.
      schema:
        type: number
        minimum: -180
        maximum: 180
    Units:
      name: units
      in: query
      description: |
        Unit system used in the response.

        - `metric` — °C, m/s, mm (default)
        - `imperial` — °F, mph, in
        - `standard` — Kelvin, m/s, mm
      schema:
        type: string
        enum:
          - metric
          - imperial
          - standard
        default: metric
    Language:
      name: language
      in: query
      description: BCP-47 language tag for human-readable strings (e.g. `en`, `en-GB`).
      schema:
        type: string
        default: en
        pattern: ^[A-Za-z][A-Za-z0-9-]{1,11}$
    ActivityType:
      name: activity_type
      in: query
      description: |
        Optional activity context for the risk engine. Free-form;
        unknown values fall back to the outdoor default profile. See
        the Endpoints doc for the canonical MVP list.
      schema:
        type: string
  headers:
    XRequestId:
      description: Unique ID for this request, also surfaced in `meta.request_id`.
      schema:
        type: string
        format: uuid
    XCacheStatus:
      description: |
        Cache outcome for the response. `MIXED` is only used by the
        timeline endpoint when items spanned multiple cells with
        different cache statuses.
      schema:
        type: string
        enum:
          - HIT
          - MISS
          - STALE
          - MIXED
    XSnapshotId:
      description: Snapshot ID that backs the response, when applicable.
      schema:
        type: string
        format: uuid
    XRateLimitLimit:
      description: Cap for the primary "requests/month" quota dimension.
      schema:
        type: integer
        minimum: 0
    XRateLimitRemaining:
      description: Requests remaining in the current monthly window.
      schema:
        type: integer
        minimum: 0
    XRateLimitReset:
      description: Unix-seconds timestamp when the monthly quota resets.
      schema:
        type: integer
        minimum: 0
  responses:
    BadRequest:
      description: Malformed request — missing or invalid fields.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: bad_request
              message: lat is required and must be a finite number
              details:
                field: lat
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    Unauthorized:
      description: |
        Missing, invalid, expired, or revoked API key. Look at
        `error.code` for the specific reason: `api_key_missing`,
        `api_key_invalid`, `api_key_expired`, `api_key_revoked`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: api_key_missing
              message: API key is required
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    Forbidden:
      description: |
        API key valid but missing the scope this endpoint requires
        (`scope_required`) or the developer account is suspended
        (`account_suspended`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: scope_required
              message: API key is missing a required scope
              details:
                required: weather:timeline
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    NotFound:
      description: Resource not found, or not owned by the calling developer account.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: not_found
              message: Resource not found
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    RateLimited:
      description: |
        Rate-limit exceeded for the calling key. The `Retry-After`
        header is not currently emitted; use the `X-RateLimit-Reset`
        header to know when the monthly quota resets.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: rate_limit_exceeded
              message: Monthly request quota exceeded
              details:
                dimension: requests
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    BillingLimitReached:
      description: |
        The calling plan flatly excludes this feature (e.g. Free tier
        calling `/v1/weather/route`). Distinct from `rate_limited`
        because no amount of waiting unblocks the request — the caller
        has to upgrade plan.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: billing_limit_reached
              message: Plan 'free' does not include route intelligence
              details:
                dimension: route_calls
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    InternalError:
      description: Unhandled internal error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: internal_error
              message: Internal server error
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    ProviderUnavailable:
      description: Upstream weather provider is unavailable. Retry with backoff.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: provider_unavailable
              message: Upstream weather provider is unavailable — please retry.
              details:
                provider: openweather
                provider_error_code: upstream_error
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
    ProviderTimeout:
      description: Upstream weather provider timed out or rate-limited us. Retry with backoff.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: weather_refresh_timeout
              message: Weather provider timed out — please retry.
              details:
                provider: openweather
                provider_error_code: rate_limited
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
            meta:
              request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
              generated_at: 2026-04-25T18:34:00.000Z
  schemas:
    ApiMeta:
      type: object
      required:
        - request_id
        - generated_at
      properties:
        request_id:
          type: string
          format: uuid
          description: Per-request UUID, also surfaced as the `X-Request-Id` header.
        generated_at:
          type: string
          format: date-time
          description: Server-side timestamp when the response was constructed.
        cache_status:
          type: string
          enum:
            - HIT
            - MISS
            - STALE
            - MIXED
        snapshot_id:
          type:
            - string
            - "null"
          format: uuid
        billing_units:
          type: integer
          minimum: 0
          description: Billing units accrued by this single request.
        items_analyzed:
          type: integer
          minimum: 0
        segments_analyzed:
          type: integer
          minimum: 0
        units:
          type: string
          enum:
            - metric
            - imperial
            - standard
        language:
          type: string
        environment:
          type: string
          enum:
            - test
            - production
      additionalProperties: true
    ErrorEnvelope:
      type: object
      required:
        - error
        - meta
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              $ref: "#/components/schemas/ErrorCode"
            message:
              type: string
            details:
              description: Code-specific structured detail (e.g. `field`, `dimension`, provider error code).
            request_id:
              type: string
              format: uuid
        meta:
          $ref: "#/components/schemas/ApiMeta"
    ErrorCode:
      type: string
      description: |
        Machine-readable error code. Exact set is pinned to PRD section 25.
      enum:
        - api_key_missing
        - api_key_invalid
        - api_key_revoked
        - api_key_expired
        - scope_required
        - account_suspended
        - rate_limited
        - rate_limit_exceeded
        - billing_limit_reached
        - bad_request
        - route_invalid
        - timeline_item_invalid
        - not_found
        - provider_unavailable
        - weather_refresh_timeout
        - internal_error
    LocationBlock:
      type: object
      required:
        - lat
        - lng
        - geohash
        - geohash_precision
        - timezone
        - place_type
      properties:
        lat:
          type: number
        lng:
          type: number
        geohash:
          type: string
        geohash_precision:
          type: integer
          minimum: 1
        timezone:
          type:
            - string
            - "null"
        place_type:
          type:
            - string
            - "null"
    Condition:
      type: object
      required:
        - code
        - label
        - icon
      properties:
        code:
          type:
            - string
            - "null"
        label:
          type:
            - string
            - "null"
        icon:
          type:
            - string
            - "null"
    WindBlock:
      type: object
      required:
        - speed_ms
        - gust_ms
        - direction_deg
      properties:
        speed_ms:
          type:
            - number
            - "null"
        gust_ms:
          type:
            - number
            - "null"
        direction_deg:
          type:
            - number
            - "null"
    CurrentBlock:
      type: object
      required:
        - observed_at
        - temperature
        - feels_like
        - humidity_pct
        - pressure_hpa
        - wind
        - cloud_cover_pct
        - visibility_m
        - uv_index
        - precipitation_mm
        - condition
      properties:
        observed_at:
          type:
            - string
            - "null"
          format: date-time
        temperature:
          type:
            - number
            - "null"
        feels_like:
          type:
            - number
            - "null"
        humidity_pct:
          type:
            - number
            - "null"
        pressure_hpa:
          type:
            - number
            - "null"
        wind:
          $ref: "#/components/schemas/WindBlock"
        cloud_cover_pct:
          type:
            - number
            - "null"
        visibility_m:
          type:
            - number
            - "null"
        uv_index:
          type:
            - number
            - "null"
        precipitation_mm:
          type:
            - number
            - "null"
        condition:
          $ref: "#/components/schemas/Condition"
    HourlyBlock:
      type: object
      required:
        - valid_at
        - temperature
        - feels_like
        - humidity_pct
        - wind
        - cloud_cover_pct
        - precipitation_mm
        - precipitation_probability_pct
        - uv_index
        - condition
      properties:
        valid_at:
          type:
            - string
            - "null"
          format: date-time
        temperature:
          type:
            - number
            - "null"
        feels_like:
          type:
            - number
            - "null"
        humidity_pct:
          type:
            - number
            - "null"
        wind:
          $ref: "#/components/schemas/WindBlock"
        cloud_cover_pct:
          type:
            - number
            - "null"
        precipitation_mm:
          type:
            - number
            - "null"
        precipitation_probability_pct:
          type:
            - number
            - "null"
        uv_index:
          type:
            - number
            - "null"
        condition:
          $ref: "#/components/schemas/Condition"
    DailyBlock:
      type: object
      required:
        - local_date
        - sunrise
        - sunset
        - temperature
        - feels_like
        - humidity_pct
        - wind
        - cloud_cover_pct
        - precipitation_mm
        - precipitation_probability_pct
        - uv_index_max
        - condition
      properties:
        local_date:
          type: string
          description: YYYY-MM-DD in the location's local timezone.
        sunrise:
          type:
            - string
            - "null"
          format: date-time
        sunset:
          type:
            - string
            - "null"
          format: date-time
        temperature:
          type: object
          properties:
            min:
              type:
                - number
                - "null"
            max:
              type:
                - number
                - "null"
          required:
            - min
            - max
        feels_like:
          type: object
          properties:
            min:
              type:
                - number
                - "null"
            max:
              type:
                - number
                - "null"
          required:
            - min
            - max
        humidity_pct:
          type:
            - number
            - "null"
        wind:
          $ref: "#/components/schemas/WindBlock"
        cloud_cover_pct:
          type:
            - number
            - "null"
        precipitation_mm:
          type:
            - number
            - "null"
        precipitation_probability_pct:
          type:
            - number
            - "null"
        uv_index_max:
          type:
            - number
            - "null"
        condition:
          $ref: "#/components/schemas/Condition"
    AlertBlock:
      type: object
      required:
        - source
        - event
        - severity
        - start
        - end
        - headline
        - description
        - tags
      properties:
        source:
          type: string
        event:
          type: string
        severity:
          type: string
          enum:
            - unknown
            - minor
            - moderate
            - severe
            - extreme
        start:
          type:
            - string
            - "null"
          format: date-time
        end:
          type:
            - string
            - "null"
          format: date-time
        headline:
          type:
            - string
            - "null"
        description:
          type:
            - string
            - "null"
        tags:
          type: array
          items:
            type: string
    RiskLevel:
      type: string
      description: Increasing severity. PRD section 17.1.
      enum:
        - low
        - info
        - watch
        - warning
        - danger
    RiskReason:
      type: object
      required:
        - code
        - level
        - message
      properties:
        code:
          type: string
        level:
          $ref: "#/components/schemas/RiskLevel"
        message:
          type: string
    RiskBlock:
      type: object
      required:
        - level
        - reasons
        - recommendation
        - activity_type
      properties:
        level:
          $ref: "#/components/schemas/RiskLevel"
        reasons:
          type: array
          items:
            $ref: "#/components/schemas/RiskReason"
        recommendation:
          type:
            - string
            - "null"
          description: Populated when `level >= watch`.
        activity_type:
          type:
            - string
            - "null"
    CurrentWeatherEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - location
            - current
            - alerts
            - risk
            - snapshot_id
            - provider
            - provider_version
            - attribution
            - fetched_at
          properties:
            location:
              $ref: "#/components/schemas/LocationBlock"
            current:
              $ref: "#/components/schemas/CurrentBlock"
            alerts:
              type: array
              items:
                $ref: "#/components/schemas/AlertBlock"
            risk:
              $ref: "#/components/schemas/RiskBlock"
            snapshot_id:
              type: string
              format: uuid
            provider:
              type: string
            provider_version:
              type:
                - string
                - "null"
            attribution:
              type:
                - string
                - "null"
            fetched_at:
              type: string
              format: date-time
    ForecastEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - location
            - granularity
            - days
            - hourly
            - daily
            - snapshot_id
            - provider
            - provider_version
            - attribution
            - fetched_at
          properties:
            location:
              $ref: "#/components/schemas/LocationBlock"
            granularity:
              type: string
              enum:
                - hourly
                - daily
            days:
              type: integer
              minimum: 1
              maximum: 8
            hourly:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/HourlyBlock"
            daily:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/DailyBlock"
            snapshot_id:
              type: string
              format: uuid
            provider:
              type: string
            provider_version:
              type:
                - string
                - "null"
            attribution:
              type:
                - string
                - "null"
            fetched_at:
              type: string
              format: date-time
    TimelineRequest:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: "#/components/schemas/TimelineItemRequest"
        units:
          type: string
          enum:
            - metric
            - imperial
            - standard
          default: metric
        language:
          type: string
          default: en
    TimelineItemRequest:
      type: object
      required:
        - start
        - lat
        - lng
      properties:
        id:
          type: string
          description: Optional caller-supplied ID. Defaults to `item_<index>`.
        start:
          type: string
          format: date-time
          description: ISO-8601 start instant.
        end:
          type: string
          format: date-time
          description: Optional ISO-8601 end instant. Must be >= `start`.
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        activity_type:
          type: string
          description: |
            Free-form activity hint. MVP canonical values: `walking`,
            `walking_tour`, `hiking`, `beach`, `dining`,
            `outdoor_dining`, `museum`, `shopping`, `driving`, `train`,
            `ferry`, `cycling`, `airport_transfer`, `event`,
            `sightseeing`, `free_time`. Unknown values fall back to the
            outdoor default profile.
        outdoor:
          type: boolean
          description: Override the activity-type → outdoor inference.
        transport_mode:
          type: string
          description: Activity transport mode (one of `WeatherTransportMode`).
        place_type:
          type: string
          description: Hints geohash precision for the cache layer.
        timezone:
          type: string
          description: |
            IANA timezone for resolving the local-date / local-hour cache
            bucket. Defaults to UTC.
        duration_minutes:
          type: number
          minimum: 0
          description: Duration override; otherwise derived from `start..end`.
    TimelineEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - items
            - summary
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/TimelineItemResponse"
            summary:
              $ref: "#/components/schemas/TimelineSummary"
    TimelineItemResponse:
      type: object
      required:
        - id
        - activity_type
        - outdoor
        - start
        - weather
        - risk
      properties:
        id:
          type: string
        activity_type:
          type:
            - string
            - "null"
        outdoor:
          type: boolean
        start:
          type: string
          format: date-time
        weather:
          type: object
          required:
            - observed_at
            - snapshot_id
            - cache_status
          properties:
            observed_at:
              type: string
              format: date-time
            snapshot_id:
              type: string
              format: uuid
            cache_status:
              type: string
              enum:
                - HIT
                - MISS
                - STALE
            temperature_c:
              type:
                - number
                - "null"
            feels_like_c:
              type:
                - number
                - "null"
            condition:
              type:
                - string
                - "null"
            precipitation_probability:
              type:
                - number
                - "null"
            precipitation_intensity_mm_h:
              type:
                - number
                - "null"
            wind_speed_kph:
              type:
                - number
                - "null"
            wind_gust_kph:
              type:
                - number
                - "null"
            uv_index:
              type:
                - number
                - "null"
        risk:
          type: object
          required:
            - level
            - reasons
          properties:
            level:
              $ref: "#/components/schemas/RiskLevel"
            reasons:
              type: array
              items:
                $ref: "#/components/schemas/RiskReason"
            recommendation:
              type: string
    TimelineSummary:
      type: object
      required:
        - overall_risk
        - main_issue
        - best_outdoor_window
      properties:
        overall_risk:
          $ref: "#/components/schemas/RiskLevel"
        main_issue:
          type:
            - string
            - "null"
        best_outdoor_window:
          oneOf:
            - type: "null"
            - type: object
              required:
                - start
                - end
                - level
              properties:
                start:
                  type: string
                  format: date-time
                end:
                  type: string
                  format: date-time
                level:
                  $ref: "#/components/schemas/RiskLevel"
    RoutePoint:
      type: object
      required:
        - lat
        - lng
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
    RouteRequest:
      type: object
      required:
        - origin
        - destination
        - transport_mode
        - start_time
      properties:
        origin:
          $ref: "#/components/schemas/RoutePoint"
        destination:
          $ref: "#/components/schemas/RoutePoint"
        transport_mode:
          type: string
          enum:
            - car
            - bus
            - train
            - ferry
            - walking
            - cycling
            - shuttle
            - transfer
        start_time:
          type: string
          format: date-time
          description: Anchor for per-segment time windows.
        polyline:
          description: |
            Optional path between origin and destination. Three formats
            accepted: an encoded Google polyline string, an array of
            `[lat, lng]` tuples, or an array of `{ lat, lng }` objects.
            When omitted, the analyzer samples a great-circle line.
          oneOf:
            - type: string
              minLength: 1
              description: Encoded Google polyline string.
            - type: array
              description: Array of points; each item may be a `[lat, lng]` tuple or a `{ lat, lng }` object.
              items:
                oneOf:
                  - type: array
                    minItems: 2
                    maxItems: 2
                    items:
                      type: number
                  - $ref: "#/components/schemas/RoutePoint"
        units:
          type: string
          enum:
            - metric
            - imperial
        language:
          type: string
          minLength: 2
          maxLength: 8
        ideal_segment_meters:
          type: number
          minimum: 1
          description: Test/internal — override sampling density.
        speed_kph:
          type: number
          minimum: 1
          description: Test/internal — override per-mode cruise speed.
        max_segments:
          type: integer
          minimum: 1
          maximum: 256
          description: Test/internal — override the segment count cap.
    RouteSegment:
      type: object
      required:
        - index
        - start_point
        - end_point
        - condition
        - risk
      properties:
        index:
          type: integer
          minimum: 0
        start_point:
          $ref: "#/components/schemas/RoutePoint"
        end_point:
          $ref: "#/components/schemas/RoutePoint"
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        distance_m:
          type: number
          minimum: 0
        duration_minutes:
          type: number
          minimum: 0
        snapshot_id:
          type:
            - string
            - "null"
          format: uuid
        cache_status:
          type: string
          enum:
            - HIT
            - MISS
            - STALE
        condition:
          type: object
          properties:
            label:
              type:
                - string
                - "null"
            temperature_c:
              type:
                - number
                - "null"
            wind_speed_kph:
              type:
                - number
                - "null"
            wind_gust_kph:
              type:
                - number
                - "null"
            precipitation_mm:
              type:
                - number
                - "null"
            visibility_km:
              type:
                - number
                - "null"
        risk:
          type: object
          required:
            - level
            - reasons
          properties:
            level:
              $ref: "#/components/schemas/RiskLevel"
            reasons:
              type: array
              items:
                $ref: "#/components/schemas/RiskReason"
            recommendation:
              type:
                - string
                - "null"
    RouteEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - segments
            - overall_risk
          properties:
            origin:
              $ref: "#/components/schemas/RoutePoint"
            destination:
              $ref: "#/components/schemas/RoutePoint"
            transport_mode:
              type: string
              enum:
                - car
                - bus
                - train
                - ferry
                - walking
                - cycling
                - shuttle
                - transfer
            segments:
              type: array
              items:
                $ref: "#/components/schemas/RouteSegment"
            overall_risk:
              type: object
              required:
                - level
              properties:
                level:
                  $ref: "#/components/schemas/RiskLevel"
                reasons:
                  type: array
                  items:
                    $ref: "#/components/schemas/RiskReason"
                recommendation:
                  type:
                    - string
                    - "null"
                main_issue:
                  type:
                    - string
                    - "null"
    ChangeRules:
      type: object
      description: |
        Thresholds that decide when a change event is recorded. All
        fields optional; the service merges defaults from PRD section
        18.2 for any missing field.
      properties:
        precipitation_probability_delta:
          type:
            - number
            - "null"
        temperature_c_delta:
          type:
            - number
            - "null"
        wind_kph_delta:
          type:
            - number
            - "null"
        uv_index_delta:
          type:
            - number
            - "null"
        detect_severe_alerts:
          type: boolean
        detect_risk_level_change:
          type: boolean
    WatchCreateRequest:
      type: object
      required:
        - target_type
        - latitude
        - longitude
        - starts_at
        - ends_at
      properties:
        target_type:
          type: string
          enum:
            - place
            - timeline_item
            - route_window
            - trip
        target_ref:
          type: object
          additionalProperties: true
          nullable: true
        latitude:
          type: number
          minimum: -90
          maximum: 90
        longitude:
          type: number
          minimum: -180
          maximum: 180
        place_type:
          type: string
          minLength: 1
          maxLength: 64
        activity_type:
          type: string
          minLength: 1
          maxLength: 64
        transport_mode:
          type: string
          minLength: 1
          maxLength: 64
        outdoor:
          type: boolean
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          description: Must be strictly after `starts_at`. Window capped at 90 days.
        change_rules:
          $ref: "#/components/schemas/ChangeRules"
        webhook_url:
          type: string
          format: uri
          description: Optional override delivery URL.
        timezone:
          type: string
        metadata:
          type: object
          additionalProperties: true
          nullable: true
    WatchSummary:
      type: object
      required:
        - watch_id
        - status
        - target_type
        - target_ref
        - starts_at
        - ends_at
        - change_rules
        - activity_type
        - transport_mode
        - outdoor
        - webhook_url
        - next_check_at
        - created_at
      properties:
        watch_id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - active
            - paused
            - expired
            - failed
            - cancelled
        target_type:
          type: string
        target_ref:
          type: object
          additionalProperties: true
          nullable: true
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
        change_rules:
          $ref: "#/components/schemas/ChangeRules"
        activity_type:
          type:
            - string
            - "null"
        transport_mode:
          type:
            - string
            - "null"
        outdoor:
          type:
            - boolean
            - "null"
        webhook_url:
          type:
            - string
            - "null"
        next_check_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
    WatchCreateEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          $ref: "#/components/schemas/WatchSummary"
    WatchGetEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          allOf:
            - $ref: "#/components/schemas/WatchSummary"
            - type: object
              properties:
                latitude:
                  type: number
                longitude:
                  type: number
                last_checked_at:
                  type:
                    - string
                    - "null"
                  format: date-time
                last_risk_level:
                  oneOf:
                    - type: "null"
                    - $ref: "#/components/schemas/RiskLevel"
                failure_count:
                  type: integer
                  minimum: 0
                updated_at:
                  type: string
                  format: date-time
                expired_at:
                  type:
                    - string
                    - "null"
                  format: date-time
                latest_snapshot:
                  oneOf:
                    - type: "null"
                    - type: object
                      properties:
                        snapshot_id:
                          type: string
                          format: uuid
                        forecast_type:
                          type: string
                          enum:
                            - current
                            - hourly
                            - daily
                        valid_from:
                          type: string
                          format: date-time
                        valid_to:
                          type: string
                          format: date-time
                        fetched_at:
                          type: string
                          format: date-time
                        freshness_band:
                          type: string
                        metrics:
                          type: object
                          additionalProperties: true
                        alerts:
                          type: array
                          items:
                            $ref: "#/components/schemas/AlertBlock"
                latest_risk:
                  oneOf:
                    - type: "null"
                    - type: object
                      properties:
                        risk_assessment_id:
                          type: string
                          format: uuid
                        level:
                          $ref: "#/components/schemas/RiskLevel"
                        recommendation:
                          type:
                            - string
                            - "null"
                        reasons:
                          type: array
                          items:
                            $ref: "#/components/schemas/RiskReason"
                recent_change_events:
                  type: array
                  items:
                    type: object
                    properties:
                      event_id:
                        type: string
                        format: uuid
                      event_type:
                        type: string
                      severity:
                        type: string
                      reason_codes:
                        type: array
                        items:
                          type: string
                      reason:
                        type:
                          - string
                          - "null"
                      recommendation:
                        type:
                          - string
                          - "null"
                      previous_snapshot_id:
                        type:
                          - string
                          - "null"
                        format: uuid
                      snapshot_id:
                        type:
                          - string
                          - "null"
                        format: uuid
                      occurred_at:
                        type: string
                        format: date-time
    WatchDeleteEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - watch_id
            - status
          properties:
            watch_id:
              type: string
              format: uuid
            status:
              type: string
              enum:
                - active
                - paused
                - expired
                - failed
                - cancelled
            expired_at:
              type:
                - string
                - "null"
              format: date-time
    HealthEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        meta:
          $ref: "#/components/schemas/ApiMeta"
        data:
          type: object
          required:
            - status
            - environment
          properties:
            status:
              type: string
              enum:
                - ok
            environment:
              type: string
  examples:
    CurrentWeatherParis:
      summary: Current weather for Paris (no activity context).
      value:
        data:
          location:
            lat: 48.8566
            lng: 2.3522
            geohash: u09tu
            geohash_precision: 5
            timezone: Europe/Paris
            place_type: city
          current:
            observed_at: 2026-04-25T18:00:00.000Z
            temperature: 14.2
            feels_like: 12.9
            humidity_pct: 71
            pressure_hpa: 1013
            wind:
              speed_ms: 4.1
              gust_ms: 7.2
              direction_deg: 220
            cloud_cover_pct: 75
            visibility_m: 10000
            uv_index: 1
            precipitation_mm: 0.2
            condition:
              code: "500"
              label: light rain
              icon: 10d
          alerts: []
          risk:
            level: low
            reasons: []
            recommendation: null
            activity_type: null
          snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
          provider: openweather
          provider_version: onecall/3.0
          attribution: Weather data provided by OpenWeather
          fetched_at: 2026-04-25T18:01:13.412Z
        meta:
          request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
          generated_at: 2026-04-25T18:01:13.500Z
          environment: production
          cache_status: HIT
          snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
          billing_units: 1
          units: metric
          language: en
    ForecastParisDaily:
      summary: 3-day daily forecast for Paris.
      value:
        data:
          location:
            lat: 48.8566
            lng: 2.3522
            geohash: u09tu
            geohash_precision: 5
            timezone: Europe/Paris
            place_type: city
          granularity: daily
          days: 3
          hourly: null
          daily:
            - local_date: 2026-04-26
              sunrise: 2026-04-26T04:55:00.000Z
              sunset: 2026-04-26T18:55:00.000Z
              temperature:
                min: 8.1
                max: 16.4
              feels_like:
                min: 6.9
                max: 15.2
              humidity_pct: 65
              wind:
                speed_ms: 3.2
                gust_ms: 6.1
                direction_deg: 200
              cloud_cover_pct: 40
              precipitation_mm: 1.2
              precipitation_probability_pct: 35
              uv_index_max: 5
              condition:
                code: "500"
                label: light rain
                icon: 10d
            - local_date: 2026-04-27
              sunrise: 2026-04-27T04:53:00.000Z
              sunset: 2026-04-27T18:57:00.000Z
              temperature:
                min: 9
                max: 18.7
              feels_like:
                min: 8
                max: 17.5
              humidity_pct: 60
              wind:
                speed_ms: 2.8
                gust_ms: 5.4
                direction_deg: 195
              cloud_cover_pct: 30
              precipitation_mm: 0
              precipitation_probability_pct: 10
              uv_index_max: 6
              condition:
                code: "800"
                label: clear
                icon: 01d
            - local_date: 2026-04-28
              sunrise: 2026-04-28T04:51:00.000Z
              sunset: 2026-04-28T18:59:00.000Z
              temperature:
                min: 10.4
                max: 19.5
              feels_like:
                min: 9.4
                max: 18.3
              humidity_pct: 58
              wind:
                speed_ms: 3
                gust_ms: 5.8
                direction_deg: 210
              cloud_cover_pct: 35
              precipitation_mm: 0.1
              precipitation_probability_pct: 15
              uv_index_max: 6
              condition:
                code: "801"
                label: few clouds
                icon: 02d
          snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
          provider: openweather
          provider_version: onecall/3.0
          attribution: Weather data provided by OpenWeather
          fetched_at: 2026-04-25T18:01:13.412Z
        meta:
          request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
          generated_at: 2026-04-25T18:01:13.500Z
          environment: production
          cache_status: HIT
          snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
          billing_units: 1
          items_analyzed: 3
          units: metric
          language: en
    TimelineRequestTwoItems:
      summary: A two-item morning itinerary in Paris.
      value:
        units: metric
        language: en
        items:
          - id: morning_walk
            start: 2026-05-15T08:00:00.000Z
            end: 2026-05-15T09:30:00.000Z
            lat: 48.8566
            lng: 2.3522
            activity_type: walking_tour
            timezone: Europe/Paris
          - id: louvre_visit
            start: 2026-05-15T10:00:00.000Z
            end: 2026-05-15T13:00:00.000Z
            lat: 48.8606
            lng: 2.3376
            activity_type: museum
            timezone: Europe/Paris
    TimelineResponseTwoItems:
      summary: Per-item weather + summary for the morning itinerary.
      value:
        data:
          items:
            - id: morning_walk
              activity_type: walking_tour
              outdoor: true
              start: 2026-05-15T08:00:00.000Z
              weather:
                observed_at: 2026-05-15T08:00:00.000Z
                snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
                cache_status: HIT
                temperature_c: 12.4
                feels_like_c: 11.2
                condition: light rain
                precipitation_probability: 65
                precipitation_intensity_mm_h: 1.2
                wind_speed_kph: 14.4
                wind_gust_kph: 22
                uv_index: 2
              risk:
                level: watch
                reasons:
                  - code: rain_likely
                    level: watch
                    message: Light rain likely (65% chance)
                recommendation: Bring a rain jacket; light rain expected during your walking tour.
            - id: louvre_visit
              activity_type: museum
              outdoor: false
              start: 2026-05-15T10:00:00.000Z
              weather:
                observed_at: 2026-05-15T10:00:00.000Z
                snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
                cache_status: HIT
                temperature_c: 13
                feels_like_c: 12
                condition: light rain
                precipitation_probability: 60
                precipitation_intensity_mm_h: 0.8
                wind_speed_kph: 12.6
                wind_gust_kph: 20.5
                uv_index: 2
              risk:
                level: low
                reasons: []
          summary:
            overall_risk: watch
            main_issue: Light rain likely (65% chance) for walking tour at 10:00 AM
            best_outdoor_window:
              start: 2026-05-15T13:00:00.000Z
              end: 2026-05-15T17:00:00.000Z
              level: low
        meta:
          request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
          generated_at: 2026-04-25T18:01:13.500Z
          environment: production
          cache_status: HIT
          snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
          billing_units: 4
          items_analyzed: 2
          units: metric
          language: en
    RouteRequestCarTrip:
      summary: A car trip from Paris to Reims at noon.
      value:
        origin:
          lat: 48.8566
          lng: 2.3522
        destination:
          lat: 49.2583
          lng: 4.0317
        transport_mode: car
        start_time: 2026-05-15T12:00:00.000Z
        units: metric
        language: en
    RouteResponseCarTrip:
      summary: Per-segment weather + overall risk for the car trip.
      value:
        data:
          origin:
            lat: 48.8566
            lng: 2.3522
          destination:
            lat: 49.2583
            lng: 4.0317
          transport_mode: car
          segments:
            - index: 0
              start_point:
                lat: 48.8566
                lng: 2.3522
              end_point:
                lat: 49.0571
                lng: 3.192
              start_time: 2026-05-15T12:00:00.000Z
              end_time: 2026-05-15T13:00:00.000Z
              distance_m: 67000
              duration_minutes: 60
              snapshot_id: 4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f
              cache_status: HIT
              condition:
                label: clear
                temperature_c: 17.2
                wind_speed_kph: 16
                wind_gust_kph: 24
                precipitation_mm: 0
                visibility_km: 10
              risk:
                level: low
                reasons: []
                recommendation: null
            - index: 1
              start_point:
                lat: 49.0571
                lng: 3.192
              end_point:
                lat: 49.2583
                lng: 4.0317
              start_time: 2026-05-15T13:00:00.000Z
              end_time: 2026-05-15T14:00:00.000Z
              distance_m: 76000
              duration_minutes: 60
              snapshot_id: 8a8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e7a
              cache_status: MISS
              condition:
                label: heavy rain
                temperature_c: 14.8
                wind_speed_kph: 38
                wind_gust_kph: 60
                precipitation_mm: 4.5
                visibility_km: 4
              risk:
                level: warning
                reasons:
                  - code: heavy_rain
                    level: warning
                    message: Heavy rain reduces visibility
                  - code: high_wind
                    level: watch
                    message: Gusty winds expected
                recommendation: Slow down and consider stopping if visibility drops further.
          overall_risk:
            level: warning
            reasons:
              - code: heavy_rain
                level: warning
                message: Heavy rain reduces visibility
            recommendation: Slow down and consider stopping if visibility drops further.
            main_issue: Heavy rain on the second leg of the drive
        meta:
          request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
          generated_at: 2026-04-25T12:00:13.500Z
          environment: production
          cache_status: MIXED
          snapshot_id: 8a8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e7a
          billing_units: 6
          items_analyzed: 2
          segments_analyzed: 2
          units: metric
          language: en
    WatchCreatePlace:
      summary: Watch a place for change events with default thresholds.
      value:
        target_type: place
        target_ref:
          place_id: city:paris
        latitude: 48.8566
        longitude: 2.3522
        place_type: city
        activity_type: walking_tour
        outdoor: true
        starts_at: 2026-05-15T00:00:00.000Z
        ends_at: 2026-05-22T00:00:00.000Z
        webhook_url: https://example.com/hooks/weather
    WatchCreateResponse:
      summary: Watch row hydrated with merged change rules.
      value:
        data:
          watch_id: 1f8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6a
          status: active
          target_type: place
          target_ref:
            place_id: city:paris
          starts_at: 2026-05-15T00:00:00.000Z
          ends_at: 2026-05-22T00:00:00.000Z
          change_rules:
            precipitation_probability_delta: 30
            temperature_c_delta: 5
            wind_kph_delta: 20
            uv_index_delta: 3
            detect_severe_alerts: true
            detect_risk_level_change: true
          activity_type: walking_tour
          transport_mode: null
          outdoor: true
          webhook_url: https://example.com/hooks/weather
          next_check_at: 2026-05-15T00:00:00.000Z
          created_at: 2026-04-25T18:01:13.500Z
        meta:
          request_id: 7c0f3c2c-9e4a-4d29-b4a2-9c2b4e9f5c11
          generated_at: 2026-04-25T18:01:13.500Z
          environment: production
          billing_units: 1
