openapi: 3.0.3
info:
  title: geog.ai Spatial Intelligence API
  version: "1.0"
  description: |
    geog.ai turns coordinates into spatial decisions. Every API call resolves location into
    enriched context — terrain, jurisdiction, neighbors, atmospheric conditions — and optionally
    runs physics-based propagation models to answer "what happens next?"

    **Base URL:** `https://api.geog.ai/v1`

    **Response modes:**
    - **Synchronous** — context queries and RF/graph lookups return immediately (typically <200 ms)
    - **Async jobs** — propagation simulations (`/simulate/*`) return a job ID; results delivered
      via webhook or polled via `GET /jobs/{job_id}`
    - **Streaming** — real-time plume tracking via SSE (`/simulate/plume/stream`, Site Calibration plans only)

    Every response is wrapped in a standard envelope that includes a confidence score (0.0–1.0),
    model version, calibration age, and data provenance.
  contact:
    email: hello@geog.ai
  license:
    name: Proprietary

servers:
  - url: https://api.geog.ai/v1
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Context API
    description: Resolve spatial state for registered devices and arbitrary coordinates.
  - name: Simulate API
    description: Physics-based propagation simulations (async jobs).
  - name: RF API
    description: Radio-frequency coverage, link budget, and mesh analysis.
  - name: Optimize API
    description: Node and sensor placement optimization; coverage and gap analysis.
  - name: Trajectory API
    description: Spatiotemporal trajectory storage, retrieval, and prediction.
  - name: Graph API
    description: Spatial graph operations — influence spread, evacuation routing, upstream tracing.
  - name: Calibration
    description: Site-specific model calibration state.
  - name: Jobs
    description: Async job status polling.
  - name: Webhooks
    description: |
      Register HTTPS callbacks to receive async job results, signed with HMAC-SHA256.

      ## Event payloads

      Every delivery, regardless of event type, uses the same JSON envelope —
      `{ ok, event, result, meta }` — where `event` matches the
      `X-Geog-Event` header and `result` is a per-event payload. Receivers
      should dispatch on `event` and parse `result` against the matching
      schema:

      | `event` | `result` schema |
      | --- | --- |
      | `job.complete` | [`JobEventPayload`](#tag/Webhooks/JobEventPayload) (`status="complete"`, `result.result` populated) — full body schema: [`JobCompleteEvent`](#tag/Webhooks/JobCompleteEvent) |
      | `job.failed` | [`JobEventPayload`](#tag/Webhooks/JobEventPayload) (`status="failed"`, `error` populated) — full body schema: [`JobFailedEvent`](#tag/Webhooks/JobFailedEvent) |
      | `calibration.updated` | [`CalibrationUpdatedPayload`](#tag/Webhooks/CalibrationUpdatedPayload) — full body schema: [`CalibrationUpdatedEvent`](#tag/Webhooks/CalibrationUpdatedEvent) |
      | `webhook.disabled` | [`WebhookDisabledPayload`](#tag/Webhooks/WebhookDisabledPayload) — same shape as `GET /webhooks/{id}/health` (`WebhookHealth`), so receivers do not need a follow-up health call. Full body schema: [`WebhookDisabledEvent`](#tag/Webhooks/WebhookDisabledEvent) |

      The discriminated union of all four POST body shapes is
      [`WebhookEventDelivery`](#tag/Webhooks/WebhookEventDelivery).

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "geog_live_sk_… or geog_test_sk_…"

  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window.
      schema:
        type: integer
        example: 1000
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
        example: 847
    X-RateLimit-Reset:
      description: Unix timestamp when the rate-limit window resets.
      schema:
        type: integer
        example: 1745208120

  schemas:
    RateLimitHeaders:
      description: Standard rate-limit headers on every response.
      type: object

    ResponseMeta:
      type: object
      description: Standard response metadata included on every successful response.
      properties:
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Model certainty for this result. Values below 0.6 rely on regional defaults; above 0.85 indicates site-calibrated data.
          example: 0.93
        model_version:
          type: string
          example: "plume-v4.1.2"
        calibration_age_days:
          type: integer
          description: Days since the last calibration update.
          example: 47
        site_calibrated:
          type: boolean
          description: "false = regional defaults used."
          example: true
        provenance:
          type: array
          items:
            type: string
          example:
            - "terrain:USGS-3DEP-1m"
            - "met:NOAA-HRRR-2026-04-21T03Z"
            - "jurisdiction:TCEQ-2025-Q4"
        request_id:
          type: string
          example: "req_01hx29sknd4bqyt8r"
        latency_ms:
          type: integer
          example: 87

    ErrorEnvelope:
      type: object
      properties:
        ok:
          type: boolean
          example: false
        error:
          type: object
          properties:
            code:
              type: string
              example: "OUTSIDE_COVERAGE_AREA"
            message:
              type: string
              example: "The requested coordinates fall outside the current coverage area."
            request_id:
              type: string
              example: "req_01hx29sknd4bqyt8r"

    Location:
      type: object
      properties:
        lat:
          type: number
          format: double
          example: 31.9686
        lon:
          type: number
          format: double
          example: -99.9018
        alt_m:
          type: number
          format: double
          example: 547.2
        h3_index:
          type: string
          example: "8944c0ba857ffff"
        indoor:
          nullable: true
          type: object
          description: Indoor position reference (null for outdoor sensors).

    WindVector:
      type: object
      properties:
        speed_ms:
          type: number
          example: 8.2
        bearing_deg:
          type: number
          example: 312

    Terrain:
      type: object
      properties:
        elevation_m:
          type: number
          example: 547.2
        slope_deg:
          type: number
          example: 3.1
        aspect_deg:
          type: number
          example: 180

    Jurisdiction:
      type: object
      properties:
        authority:
          type: string
          example: "TCEQ"
        permit_id:
          type: string
          example: "PSDTX1234"
        limit_ppm:
          type: number
          example: 10

    SpatialState:
      type: object
      description: The canonical spatial context object returned by context endpoints.
      properties:
        entity_id:
          type: string
          example: "sensor_h2s_023"
        location:
          $ref: "#/components/schemas/Location"
        time:
          type: string
          format: date-time
          example: "2026-04-21T03:14:22Z"
        context:
          type: object
          properties:
            wind_vector:
              $ref: "#/components/schemas/WindVector"
            terrain:
              $ref: "#/components/schemas/Terrain"
            atmospheric_stability:
              type: string
              description: "Pasquill-Gifford stability class (A–F)."
              example: "D"
            nearby_entities:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                  distance_m:
                    type: number
                  bearing_deg:
                    type: number
                  reading_ppm:
                    type: number
            jurisdiction:
              $ref: "#/components/schemas/Jurisdiction"
            asset_proximity:
              type: array
              items:
                type: object
                properties:
                  asset_id:
                    type: string
                  distance_m:
                    type: number
                  type:
                    type: string

    AsyncJobResponse:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        job:
          type: object
          properties:
            id:
              type: string
              example: "job_01hx2a3f9k8qmzn7p"
            type:
              type: string
              example: "simulate/plume"
            status:
              type: string
              enum: [queued, running, complete, failed]
              example: "queued"
            eta_seconds:
              type: integer
              example: 8
            poll_url:
              type: string
              format: uri
              example: "https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p"
            webhook_delivered:
              type: boolean
              example: false
        meta:
          type: object
          properties:
            request_id:
              type: string
              example: "req_01hx29sknd4bqyt8r"

    WebhookRegistration:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: |
            HTTPS endpoint that will receive POST deliveries. Must be reachable from the public
            internet, must use HTTPS, and must respond with a 2xx status within 10 seconds.
          example: "https://api.acme.com/hooks/geog"
        events:
          type: array
          description: |
            Filter deliveries to specific event types. Defaults to all events.

            The `webhook.disabled` event fires when this webhook is auto-disabled because
            ≥50% of deliveries in the last hour failed. It is a self-event — it is delivered
            on the **last** retry attempt before the webhook is paused, with `X-Geog-Test:
            false`. Subscribe at least one separately-monitored receiver (or a different
            webhook) to this event so you are not dependent on the failing endpoint to learn
            that it has been disabled. The same state is also queryable via
            `GET /webhooks/{id}/health`.
          items:
            type: string
            enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          example: ["job.complete", "job.failed", "webhook.disabled"]
        description:
          type: string
          maxLength: 200
          description: Human-readable label shown on the dashboard.
          example: "Production plume results → SOC pipeline"
        active:
          type: boolean
          default: true
          description: Whether the webhook should start delivering immediately.

    WebhookUpdate:
      type: object
      description: |
        Partial update for a registered webhook. Only fields present in the body are changed.
        The `url` may be re-pointed (still HTTPS-only); the signing `secret` cannot be modified
        through this endpoint — to rotate the secret, delete the webhook and register a new one.
      properties:
        url:
          type: string
          format: uri
          example: "https://api.acme.com/hooks/geog-v2"
        events:
          type: array
          items:
            type: string
            enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          example: ["job.complete", "job.failed", "webhook.disabled"]
        description:
          type: string
          maxLength: 200
          example: "Production plume results → SOC pipeline (v2)"
        active:
          type: boolean
          description: Set to `false` to pause deliveries without deleting the webhook.
          example: false

    Webhook:
      type: object
      properties:
        id:
          type: string
          example: "whk_01hx2f8q7n3tdvm5j"
        url:
          type: string
          format: uri
          example: "https://api.acme.com/hooks/geog"
        events:
          type: array
          items:
            type: string
            enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          example: ["job.complete", "job.failed", "webhook.disabled"]
        description:
          type: string
          example: "Production plume results → SOC pipeline"
        active:
          type: boolean
          description: |
            `true` while the webhook is delivering normally. Becomes `false` when the
            owner pauses it via `PATCH /webhooks/{id}` (`active=false`) **or** when the
            platform auto-disables it after sustained delivery failures (≥50% failed
            deliveries in the last hour). Inspect `disabled_at` and `disabled_reason`
            to distinguish the two cases, or call `GET /webhooks/{id}/health` for the
            full picture.
          example: true
        disabled_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp at which `active` last transitioned from `true` to `false`. `null`
            while the webhook is active. Cleared back to `null` the next time the webhook
            is re-enabled via `PATCH /webhooks/{id}` with `active=true`.
          example: null
        disabled_reason:
          type: string
          nullable: true
          enum: [manual, auto_failure_rate]
          description: |
            Why the webhook is currently disabled. `null` when `active=true`.

            - `manual` — the owner set `active=false` via `PATCH /webhooks/{id}`.
            - `auto_failure_rate` — the platform auto-disabled the webhook because ≥50%
              of deliveries in the last hour failed. An email is sent to the account
              owner and (if subscribed) a `webhook.disabled` event is delivered. Use
              `GET /webhooks/{id}/health` to see the failure counters that triggered it.
          example: "auto_failure_rate"
        secret:
          type: string
          description: |
            Shared secret used to compute the `X-Geog-Signature` HMAC-SHA256 on every delivery.
            Returned only on the initial `POST /webhooks` registration response — it is never
            included in `GET /webhooks`, `GET /webhooks/{webhook_id}`, or `PATCH /webhooks/{webhook_id}`
            responses. To rotate, `DELETE` the webhook and register a new one.
          example: "whsec_8k3Jq2pNvR7tYxL4mZ9cFwH1bD5sQ6aU"
        created_at:
          type: string
          format: date-time
          example: "2026-04-21T03:14:22Z"

    WebhookDelivery:
      type: object
      description: |
        A single attempted delivery of a webhook payload. One logical delivery may include
        multiple attempts: the `attempt_count` increments for every retry, while the `id`
        and original `signature_timestamp` remain stable across retries and replays so the
        receiver can deduplicate.
      properties:
        id:
          type: string
          description: Unique delivery identifier (also sent as the `X-Geog-Delivery` header).
          example: "whd_01hx2g4n8p7rqzm3k"
        webhook_id:
          type: string
          description: ID of the registered webhook that produced this delivery.
          example: "whk_01hx2f8q7n3tdvm5j"
        event:
          type: string
          enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          example: "job.complete"
        job_id:
          type: string
          nullable: true
          description: For `job.*` events, the source async job ID. `null` for non-job events (`calibration.updated`, `webhook.disabled`).
          example: "job_01hx2a3f9k8qmzn7p"
        status:
          type: string
          enum: [pending, success, failed, undeliverable]
          description: |
            - `pending` — queued or currently in-flight (waiting for response or next retry)
            - `success` — receiver returned a 2xx within 10 s on some attempt
            - `failed` — last attempt was non-2xx, timed out, or connection-refused, and another retry is still scheduled
            - `undeliverable` — all 7 retry attempts have been exhausted; will not be retried automatically
          example: "failed"
        attempt_count:
          type: integer
          minimum: 1
          maximum: 7
          description: Number of delivery attempts made so far (including the original send).
          example: 3
        response_code:
          type: integer
          nullable: true
          description: HTTP status code returned by the receiver on the most recent attempt. `null` if the request timed out or the connection was refused.
          example: 502
        response_ms:
          type: integer
          nullable: true
          description: Time in milliseconds from request send to response received on the most recent attempt. `null` for transport failures or while still `pending`.
          example: 482
        response_body_excerpt:
          type: string
          nullable: true
          description: First 512 bytes of the receiver's response body on the most recent attempt, useful for debugging 4xx/5xx errors. `null` for transport failures.
          example: "<html><head><title>502 Bad Gateway</title></head>..."
        error:
          type: string
          nullable: true
          description: Transport-level error if the receiver could not be reached (e.g. `connection_timeout`, `dns_failure`, `tls_handshake_failed`). `null` when an HTTP response was received.
          example: null
        signature_timestamp:
          type: integer
          description: |
            Unix timestamp (seconds) used to build the original `X-Geog-Signature` header.
            Stable across retries and replays — preserved so the receiver's HMAC verification
            and replay-window check behave identically to the original delivery.
          example: 1745211262
        created_at:
          type: string
          format: date-time
          description: When the delivery was first queued.
          example: "2026-04-21T03:14:22Z"
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the most recent attempt. `null` while `status` is `pending` and no attempt has yet been made.
          example: "2026-04-21T03:24:51Z"
        delivered_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the first successful (2xx) attempt. `null` until `status` becomes `success`. Stable once set — subsequent replays do not overwrite it.
          example: "2026-04-21T03:24:51Z"
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: Scheduled time of the next retry. `null` once `status` is `success` or `undeliverable`.
          example: "2026-04-21T03:34:51Z"
        test:
          type: boolean
          default: false
          description: |
            `true` for synthetic deliveries produced by `POST /webhooks/{id}/test`, `false` for real
            job-driven deliveries. The same flag is surfaced on the outbound POST as the
            `X-Geog-Test: true` header so the receiver can route test traffic away from production
            side-effects (alerting, billing, downstream writes) without parsing the body.
          example: false

    WebhookHealth:
      type: object
      description: |
        Rolling-window health snapshot for a single registered webhook. Exposes the same
        delivery counters and failure-rate calculation that drives the auto-disable rule
        (≥50% failed deliveries in the last hour pauses the webhook), so receivers can
        detect imminent or completed auto-disables without parsing the deliveries log or
        waiting for the owner's email.

        Counters cover **logical deliveries** (one per source event), not individual retry
        attempts: a delivery that succeeds on its third attempt counts once toward
        `success_count`, not three times. The window is a sliding 60-minute window ending
        at `as_of`; values change continuously as deliveries age out.
      properties:
        webhook_id:
          type: string
          example: "whk_01hx2f8q7n3tdvm5j"
        active:
          type: boolean
          description: Current `active` state of the webhook. Mirrors `Webhook.active`.
          example: false
        status:
          type: string
          enum: [healthy, degraded, disabled]
          description: |
            Quick categorical health summary derived from the rolling window:
            - `healthy` — `active=true` and `failure_rate < 0.25`.
            - `degraded` — `active=true` and `0.25 ≤ failure_rate < auto_disable_threshold`. The webhook is still delivering but is approaching the auto-disable line.
            - `disabled` — `active=false`, regardless of reason. Inspect `disabled_reason`.
          example: "disabled"
        disabled_at:
          type: string
          format: date-time
          nullable: true
          description: When the webhook was last paused. `null` while `active=true`.
          example: "2026-04-21T03:48:11Z"
        disabled_reason:
          type: string
          nullable: true
          enum: [manual, auto_failure_rate]
          description: |
            Why the webhook is currently paused. `null` while `active=true`.
            See the `Webhook.disabled_reason` field for the full enum description.
          example: "auto_failure_rate"
        window_seconds:
          type: integer
          description: Length of the rolling window the counters cover. Currently fixed at 3600 (1 hour).
          example: 3600
        as_of:
          type: string
          format: date-time
          description: Timestamp at the trailing edge of the window — i.e. "now" on the platform side.
          example: "2026-04-21T03:48:11Z"
        delivery_count:
          type: integer
          description: Total logical deliveries (success + failed + undeliverable + pending) attempted in the window.
          example: 42
        success_count:
          type: integer
          description: Logical deliveries that received a 2xx within 10 s on some attempt.
          example: 18
        failed_count:
          type: integer
          description: Logical deliveries whose latest attempt was non-2xx, timed out, or connection-refused, with another retry still scheduled.
          example: 3
        undeliverable_count:
          type: integer
          description: Logical deliveries that exhausted all 7 retry attempts in the window without ever returning 2xx.
          example: 21
        pending_count:
          type: integer
          description: Logical deliveries currently `pending` (queued or in-flight, no attempt resolved yet).
          example: 0
        failure_rate:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: |
            `(failed_count + undeliverable_count) / max(1, delivery_count - pending_count)`.
            This is the exact value compared to `auto_disable_threshold` once per minute.
            `0` when there have been no resolved deliveries in the window.
          example: 0.571
        auto_disable_threshold:
          type: number
          format: float
          description: |
            Failure rate at or above which the platform auto-disables the webhook on the
            next evaluation tick, provided `delivery_count - pending_count ≥ 4` (small
            sample sizes are ignored to avoid flapping). Currently `0.5`.
          example: 0.5
        consecutive_failures:
          type: integer
          description: Number of consecutive deliveries (newest-first) whose latest attempt is `failed` or `undeliverable`. Resets to 0 on the next `success`.
          example: 7
        last_success_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the most recent successful delivery in the window. `null` if no delivery has succeeded in the last hour.
          example: "2026-04-21T03:11:08Z"
        last_failure_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the most recent `failed` or `undeliverable` delivery in the window. `null` if every delivery in the window succeeded.
          example: "2026-04-21T03:47:54Z"

    # ─── Webhook event payloads (POST body sent to the receiver) ─────────────
    #
    # All deliveries share a common envelope (`ok`, `event`, `result`, `meta`).
    # The shape of `result` depends on the value of `event` (which always
    # matches the `X-Geog-Event` header). Receivers should dispatch on `event`
    # and parse `result` against the per-event payload schemas below.

    WebhookDisabledPayload:
      description: |
        `result` block of a `webhook.disabled` delivery. Fired exactly once on
        the final retry attempt before the platform pauses a webhook because
        ≥50% of deliveries in the rolling 1-hour window failed.

        The shape is intentionally identical to the `result` returned by
        `GET /webhooks/{id}/health` at the moment of the auto-disable so a
        receiver that already parses health-poll responses can reuse the same
        parser — and so a receiver that handles this event does not need a
        follow-up `GET /webhooks/{id}/health` call to know which counters
        tripped the auto-disable.

        `disabled_reason` is always `auto_failure_rate` for this event;
        `manual` pauses (owner set `active=false` via `PATCH /webhooks/{id}`)
        do not fire `webhook.disabled`.
      allOf:
        - $ref: "#/components/schemas/WebhookHealth"

    WebhookDisabledEvent:
      type: object
      description: |
        Full POST body delivered to the receiver when `event=webhook.disabled`.
        Sent on the final retry attempt before the webhook is paused. To avoid
        depending on the failing endpoint to learn that it is failing,
        subscribe at least one separately-monitored receiver to this event and
        pair it with polling on `GET /webhooks/{id}/health` as a backstop.
      required: [ok, event, result]
      properties:
        ok:
          type: boolean
          example: true
        event:
          type: string
          enum: [webhook.disabled]
          description: Always `webhook.disabled` for this payload. Matches the `X-Geog-Event` header.
          example: "webhook.disabled"
        result:
          $ref: "#/components/schemas/WebhookDisabledPayload"
        meta:
          $ref: "#/components/schemas/ResponseMeta"
      example:
        ok: true
        event: "webhook.disabled"
        result:
          webhook_id: "whk_01hx2f8q7n3tdvm5j"
          active: false
          status: "disabled"
          disabled_at: "2026-04-21T03:48:11Z"
          disabled_reason: "auto_failure_rate"
          window_seconds: 3600
          as_of: "2026-04-21T03:48:11Z"
          delivery_count: 42
          success_count: 18
          failed_count: 3
          undeliverable_count: 21
          pending_count: 0
          failure_rate: 0.571
          auto_disable_threshold: 0.5
          consecutive_failures: 7
          last_success_at: "2026-04-21T03:11:08Z"
          last_failure_at: "2026-04-21T03:47:54Z"
        meta:
          request_id: "req_01hx2g4n8p7rqzm3k"
          model_version: "webhooks-v1.0.0"

    JobEventPayload:
      type: object
      description: |
        `result` block of a `job.complete` or `job.failed` delivery. Identical
        in shape to the body returned by `GET /jobs/{job_id}` once the job has
        reached a terminal state, so receivers that also poll the Jobs API can
        share a single parser. Distinguish on `status`: `complete` jobs carry
        the simulation/optimization output in `result.result` and have no
        `error`; `failed` jobs have `result.result = null` and a populated
        `error` block.
      required: [id, type, status, created_at]
      properties:
        id:
          type: string
          description: Source async job ID.
          example: "job_01hx2a3f9k8qmzn7p"
        type:
          type: string
          description: Job type — same value as on `POST /simulate/*`, `POST /rf/*`, `POST /optimize/*`.
          example: "simulate/plume"
        status:
          type: string
          enum: [complete, failed]
          description: Terminal state at the moment the event was fired.
          example: "complete"
        created_at:
          type: string
          format: date-time
          example: "2026-04-21T03:14:22Z"
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp the job reached its terminal `status`.
          example: "2026-04-21T03:14:30Z"
        result:
          type: object
          nullable: true
          description: Endpoint-specific output for `status=complete`. `null` for `status=failed`.
        error:
          type: object
          nullable: true
          description: Populated only for `status=failed`. Same `code` + `message` shape as `ErrorEnvelope.error`.
          properties:
            code:
              type: string
              example: "SOLVER_DIVERGED"
            message:
              type: string
              example: "Plume solver did not converge after 200 iterations"

    JobCompleteEvent:
      type: object
      description: |
        Full POST body delivered to the receiver when `event=job.complete`.
        Fired once per async `/simulate/*`, `/rf/*`, or `/optimize/*` job that
        transitions to `complete`. The `result` block is byte-for-byte the
        same as `GET /jobs/{job_id}` at completion time.
      required: [ok, event, result]
      properties:
        ok:
          type: boolean
          example: true
        event:
          type: string
          enum: [job.complete]
          example: "job.complete"
        result:
          $ref: "#/components/schemas/JobEventPayload"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    JobFailedEvent:
      type: object
      description: |
        Full POST body delivered to the receiver when `event=job.failed`.
        Fired once per async job that exhausts its server-side retries.
        `result.result` is always `null`; inspect `result.error.code` to
        decide whether to retry on your side.
      required: [ok, event, result]
      properties:
        ok:
          type: boolean
          example: true
        event:
          type: string
          enum: [job.failed]
          example: "job.failed"
        result:
          $ref: "#/components/schemas/JobEventPayload"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    CalibrationUpdatedPayload:
      type: object
      description: |
        `result` block of a `calibration.updated` delivery. Fired when a
        site's calibration parameters are re-fit (Site Calibration plan
        only). Shape mirrors the relevant fields of `GET /model/calibration`
        for the affected site.
      required: [site_id, model_version, updated_at]
      properties:
        site_id:
          type: string
          example: "wellpad_07"
        model_version:
          type: string
          example: "plume-v4.1.2"
        calibration_age_days:
          type: integer
          description: Days since the previous calibration. `0` immediately after a re-fit.
          example: 0
        updated_at:
          type: string
          format: date-time
          example: "2026-04-21T03:30:00Z"
        accuracy_metrics:
          type: object
          properties:
            r_squared:
              type: number
              format: float
              example: 0.94
            rmse_ppm:
              type: number
              format: float
              example: 0.18
            sample_count:
              type: integer
              example: 1847

    CalibrationUpdatedEvent:
      type: object
      description: Full POST body delivered to the receiver when `event=calibration.updated`.
      required: [ok, event, result]
      properties:
        ok:
          type: boolean
          example: true
        event:
          type: string
          enum: [calibration.updated]
          example: "calibration.updated"
        result:
          $ref: "#/components/schemas/CalibrationUpdatedPayload"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    WebhookEventDelivery:
      description: |
        Discriminated union of every possible POST body the platform may
        deliver to a registered webhook. Receivers should dispatch on the
        `event` field (which always matches the `X-Geog-Event` header) and
        parse `result` against the matching per-event schema.
      oneOf:
        - $ref: "#/components/schemas/JobCompleteEvent"
        - $ref: "#/components/schemas/JobFailedEvent"
        - $ref: "#/components/schemas/CalibrationUpdatedEvent"
        - $ref: "#/components/schemas/WebhookDisabledEvent"
      discriminator:
        propertyName: event
        mapping:
          job.complete: "#/components/schemas/JobCompleteEvent"
          job.failed: "#/components/schemas/JobFailedEvent"
          calibration.updated: "#/components/schemas/CalibrationUpdatedEvent"
          webhook.disabled: "#/components/schemas/WebhookDisabledEvent"

    PlumeStreamRequest:
      type: object
      required: [source, duration_min]
      description: |
        Request body for `/simulate/plume/stream`. Shares the `source`, `wind`,
        `stability_class`, `duration_min`, `species`, and `output_receptors`
        shape with the async `/simulate/plume` endpoint and adds two
        streaming-specific options.
      properties:
        source:
          type: object
          required: [lat, lon, emission_rate_gs]
          description: Emission source geometry (same shape as `/simulate/plume`).
          properties:
            lat: { type: number, example: 31.9642 }
            lon: { type: number, example: -99.9035 }
            alt_m: { type: number, example: 545 }
            emission_rate_gs:
              type: number
              description: Emission rate in grams per second.
              example: 2.4
            stack_height_m:
              type: number
              description: Effective stack height in metres. Default 0 (ground level).
              example: 12
        wind:
          type: object
          description: Overrides live met data if provided.
          properties:
            speed_ms: { type: number, example: 8.2 }
            bearing_deg: { type: number, example: 312 }
        stability_class:
          type: string
          enum: [A, B, C, D, E, F]
          description: Pasquill-Gifford atmospheric stability class. Auto-resolved from met data if omitted.
          example: "D"
        duration_min:
          type: integer
          description: Total simulation duration in minutes. The stream closes with an `end` event when this elapses.
          example: 60
        species:
          type: string
          description: "Chemical species (e.g. H2S, CH4, NO2, PM25). Default: generic passive scalar."
          example: "H2S"
        output_receptors:
          type: array
          items:
            type: string
          description: Device IDs whose predicted concentrations should be included in each `concentration` event.
          example: ["sensor_h2s_021", "sensor_h2s_023", "sensor_h2s_024"]
        tick_interval_s:
          type: integer
          minimum: 1
          maximum: 60
          default: 5
          description: Seconds between `tick` heartbeat events. Lower values yield smoother UI updates at the cost of bandwidth.
          example: 5
        alert_thresholds:
          type: object
          description: Concentration thresholds in ppm that trigger `alert` events when crossed at any registered receptor or isopleth.
          additionalProperties:
            type: number
          example:
            warn: 1.0
            action: 5.0
            evacuate: 10.0

    PlumeStreamTick:
      type: object
      description: Heartbeat event emitted every `tick_interval_s` seconds while the simulation runs.
      properties:
        sim_time_s:
          type: integer
          description: Seconds elapsed since the simulation started.
          example: 30
        wall_time:
          type: string
          format: date-time
          example: "2026-04-21T03:14:52Z"
        progress:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Fraction of `duration_min` completed.
          example: 0.0083
        wind_vector:
          $ref: "#/components/schemas/WindVector"

    PlumeStreamConcentration:
      type: object
      description: |
        Updated concentration field at the current simulation time. Emitted on
        every tick after the first concentration field stabilises.
      properties:
        sim_time_s:
          type: integer
          example: 30
        isopleths:
          type: array
          description: Concentration isopleth polygons, ordered from highest to lowest threshold.
          items:
            type: object
            properties:
              concentration_ppm:
                type: number
                example: 10
              polygon:
                $ref: "#/components/schemas/GeoJSONPolygon"
        receptors:
          type: array
          description: Predicted concentration at each `output_receptors` device.
          items:
            type: object
            properties:
              device_id:
                type: string
                example: "sensor_h2s_023"
              concentration_ppm:
                type: number
                example: 4.7
        max_concentration_ppm:
          type: number
          example: 47.3

    PlumeStreamAlert:
      type: object
      description: Emitted when any receptor reading or isopleth crosses one of the configured `alert_thresholds`.
      properties:
        sim_time_s:
          type: integer
          example: 45
        threshold:
          type: string
          description: Threshold key from `alert_thresholds` that was crossed.
          example: "action"
        threshold_ppm:
          type: number
          example: 5.0
        observed_ppm:
          type: number
          example: 6.2
        device_id:
          type: string
          nullable: true
          description: Receptor that crossed the threshold. `null` if the alert was raised by an isopleth crossing rather than a registered device.
          example: "sensor_h2s_023"
        location:
          $ref: "#/components/schemas/Location"

    PlumeStreamEnd:
      type: object
      description: Terminal event sent once the simulation reaches `duration_min` or is cancelled. Clients should stop reconnecting after receiving this.
      properties:
        sim_time_s:
          type: integer
          example: 3600
        reason:
          type: string
          enum: [completed, cancelled, error]
          example: "completed"
        summary:
          type: object
          properties:
            ticks_emitted:
              type: integer
              example: 720
            alerts_emitted:
              type: integer
              example: 3
            max_concentration_ppm:
              type: number
              example: 58.1
            job_id:
              type: string
              description: Identifier for the persisted run, for later retrieval via `GET /jobs/{job_id}`.
              example: "job_01hx2a3f9k8qmzn7p"

    GeoJSONPolygon:
      type: object
      description: A GeoJSON Polygon or MultiPolygon geometry object.
      properties:
        type:
          type: string
          enum: [Polygon, MultiPolygon]
        coordinates:
          type: array
          items: {}

    GeoJSONLineString:
      type: object
      description: A GeoJSON LineString geometry object.
      properties:
        type:
          type: string
          enum: [LineString]
        coordinates:
          type: array
          items:
            type: array
            items:
              type: number

    GeoJSONPoint:
      type: object
      description: A GeoJSON Point geometry object. `coordinates` is `[longitude, latitude]` or `[longitude, latitude, altitude]`.
      required: [type, coordinates]
      properties:
        type:
          type: string
          enum: [Point]
        coordinates:
          type: array
          minItems: 2
          maxItems: 3
          items:
            type: number
          description: "[longitude, latitude] or [longitude, latitude, altitude]."
          example: [-119.42, 34.21]

    # ─── STAC Catalog (geog-ai-14) ──────────────────────────────────────────
    StacLink:
      type: object
      required: [rel, href]
      properties:
        rel:    { type: string, example: self }
        href:   { type: string, format: uri }
        type:   { type: string, example: application/json }
        title:  { type: string }
        method: { type: string, enum: [GET, POST] }
        body:   { type: object, additionalProperties: true }

    StacProvider:
      type: object
      required: [name, roles]
      properties:
        name:        { type: string, example: geog.ai }
        description: { type: string }
        roles:
          type: array
          items: { type: string, enum: [licensor, producer, processor, host] }
        url:         { type: string, format: uri }

    StacExtent:
      type: object
      required: [spatial, temporal]
      properties:
        spatial:
          type: object
          required: [bbox]
          properties:
            bbox:
              type: array
              minItems: 1
              items:
                type: array
                minItems: 4
                maxItems: 4
                items: { type: number }
              example: [[-180, -90, 180, 90]]
        temporal:
          type: object
          required: [interval]
          properties:
            interval:
              type: array
              minItems: 1
              items:
                type: array
                minItems: 2
                maxItems: 2
                items:
                  type: string
                  format: date-time
                  nullable: true

    StacCollection:
      type: object
      required: [type, stac_version, id, description, license, extent, links]
      properties:
        type:           { type: string, enum: [Collection] }
        stac_version:   { type: string, example: "1.0.0" }
        stac_extensions:
          type: array
          items: { type: string, format: uri }
        id:             { type: string, example: "plume_aermod__oilgas" }
        title:          { type: string }
        description:    { type: string }
        keywords:       { type: array, items: { type: string } }
        license:
          type: string
          description: |
            Per-tenant license slug derived from the caller's plan:
            `proprietary-free`, `proprietary-starter`, `proprietary-pro`,
            `proprietary-enterprise`. Legacy callers may receive bare
            `proprietary`.
          example: proprietary-pro
        providers:
          type: array
          items: { $ref: "#/components/schemas/StacProvider" }
        extent: { $ref: "#/components/schemas/StacExtent" }
        summaries:
          type: object
          additionalProperties: true
        links:
          type: array
          items: { $ref: "#/components/schemas/StacLink" }

    StacItem:
      type: object
      required: [type, stac_version, id, geometry, properties, links, assets, collection]
      properties:
        type:           { type: string, enum: [Feature] }
        stac_version:   { type: string, example: "1.0.0" }
        stac_extensions:
          type: array
          items: { type: string, format: uri }
        id:             { type: string, description: "geo_simulation_jobs.job_id" }
        collection:     { type: string, example: "plume_aermod__oilgas" }
        geometry:
          type: object
          nullable: true
          additionalProperties: true
        bbox:
          type: array
          minItems: 4
          maxItems: 4
          items: { type: number }
        properties:
          type: object
          additionalProperties: true
          description: |
            Includes `datetime` (completion timestamp), `geog:engine`,
            `geog:vertical_id`, `geog:status`, and any
            engine-emitted summary metrics.
        assets:
          type: object
          additionalProperties:
            type: object
            properties:
              href:  { type: string, format: uri }
              type:  { type: string }
              title: { type: string }
              roles:
                type: array
                items: { type: string }
        links:
          type: array
          items: { $ref: "#/components/schemas/StacLink" }

    StacItemCollection:
      type: object
      required: [type, features, links, numberMatched, numberReturned]
      properties:
        type: { type: string, enum: [FeatureCollection] }
        features:
          type: array
          items: { $ref: "#/components/schemas/StacItem" }
        links:
          type: array
          items: { $ref: "#/components/schemas/StacLink" }
        numberMatched:
          type: integer
          description: Total Items matching the query across all pages.
        numberReturned:
          type: integer
          description: Items in this page (≤ `limit`).

    StacSearchBody:
      type: object
      properties:
        bbox:
          type: array
          minItems: 4
          maxItems: 4
          items: { type: number }
        datetime: { type: string }
        collections:
          type: array
          items: { type: string }
        limit:
          type: integer
          minimum: 1
          maximum: 500
          default: 50
        cursor: { type: string }

    # ─── Simulate / RF / Optimize / Trajectory request bodies ─────────────────
    # These are extracted as named schemas so the SDK codegen produces real
    # types (instead of forcing the hand-written `Record<string, unknown>` /
    # `Mapping[str, Any]` fallbacks).

    ThermalSource:
      type: object
      description: Heat source specification for `/simulate/thermal`.
      required: [lat, lon, power_kw, temperature_c]
      properties:
        lat:
          type: number
          example: 31.9642
        lon:
          type: number
          example: -99.9035
        power_kw:
          type: number
          description: Thermal output in kilowatts.
          example: 850
        temperature_c:
          type: number
          description: Source surface temperature in Celsius.
          example: 240

    FireWeather:
      type: object
      description: Weather override block for `/simulate/fire`. Replaces live met data when provided.
      properties:
        wind_speed_ms:
          type: number
          example: 8.3
        wind_bearing_deg:
          type: number
          example: 225
        rh_pct:
          type: number
          description: Relative humidity percentage.
          example: 12
        temp_c:
          type: number
          description: Air temperature in Celsius.
          example: 38

    RFFailureScenario:
      type: object
      description: Single `{node_id, failure_probability}` resilience scenario for `/rf/mesh/viability`.
      properties:
        node_id:
          type: string
          example: "rf_node_A7"
        failure_probability:
          type: number
          example: 0.05

    PlacementConstraints:
      type: object
      description: Optional siting constraints for `/rf/optimize/placement`.
      properties:
        min_distance_from_edge_m:
          type: number
        exclude_polygons:
          type: array
          items:
            $ref: "#/components/schemas/GeoJSONPolygon"
        require_road_access:
          type: boolean

    SimulateRFCoverageRequest:
      type: object
      description: |
        Request body for `POST /simulate/rf_coverage`. Either `node_id` or the
        `lat`/`lon`/`alt_m` triple must be provided.
      required: [frequency_mhz, tx_power_dbm]
      properties:
        node_id:
          type: string
          description: Registered RF node ID. Required unless lat/lon/alt_m provided.
          example: "rf_node_A7"
        lat:
          type: number
          description: Node latitude. Required unless node_id provided.
        lon:
          type: number
          description: Node longitude. Required unless node_id provided.
        alt_m:
          type: number
          description: Node altitude in metres. Required unless node_id provided.
        frequency_mhz:
          type: number
          description: Carrier frequency in MHz.
          example: 915
        tx_power_dbm:
          type: number
          description: Transmit power in dBm.
          example: 20
        rssi_threshold_dbm:
          type: number
          description: RSSI threshold for coverage polygon. Default -90 dBm.
          default: -90
          example: -95
        antenna_height_m:
          type: number
          description: Antenna height above ground in metres. Default 3 m.
          default: 3
        model:
          type: string
          enum: [itm, hata, two_ray]
          description: "Propagation model. itm = terrain-aware (default), hata = urban macro-cell, two_ray = near-field."
          default: itm
        webhook_url:
          type: string
          format: uri

    SimulateNoiseRequest:
      type: object
      description: |
        Request body for `POST /simulate/noise`. Either `source_id` or a source
        geometry must be provided.
      required: [octave_bands_db]
      properties:
        source_id:
          type: string
          description: Registered noise source ID. Required unless source geometry provided.
          example: "compressor_C4"
        octave_bands_db:
          type: array
          items:
            type: number
          minItems: 8
          maxItems: 8
          description: Sound power levels (dB) for 63, 125, 250, 500, 1k, 2k, 4k, 8k Hz octave bands.
          example: [82, 86, 91, 95, 93, 88, 84, 79]
        contour_levels_dba:
          type: array
          items:
            type: number
          description: "dBA levels for contour polygons. Default: [35, 45, 55, 65, 75]."
          example: [45, 55, 65]
        receptors:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Specific receptor points to evaluate (e.g. property boundary centroids as GeoJSON Point objects).
        webhook_url:
          type: string
          format: uri

    SimulateThermalRequest:
      type: object
      description: Request body for `POST /simulate/thermal`.
      required: [source, duration_min]
      properties:
        source:
          $ref: "#/components/schemas/ThermalSource"
        duration_min:
          type: integer
          description: Simulation duration in minutes.
          example: 120
        threshold_c:
          type: array
          items:
            type: number
          description: "Temperature thresholds for zone boundary polygons. Default: [40, 60, 80]."
          example: [40, 60, 80]
        output_receptors:
          type: array
          items:
            type: string
          description: Device IDs to predict temperature at.
          example: ["sensor_temp_07", "sensor_temp_12"]
        webhook_url:
          type: string
          format: uri

    SimulateFireRequest:
      type: object
      description: Request body for `POST /simulate/fire`.
      required: [ignition_point, fuel_model, duration_hr]
      properties:
        ignition_point:
          $ref: "#/components/schemas/GeoJSONPoint"
        fuel_model:
          type: string
          description: "NFFL/Scott-Burgan fuel model code (e.g. GR2, SH5, TU1)."
          example: "GR2"
        weather:
          $ref: "#/components/schemas/FireWeather"
        duration_hr:
          type: number
          description: Simulation duration in hours. Max 72 hr.
          example: 24
        output_intervals_hr:
          type: array
          items:
            type: number
          description: "Times at which to output perimeter polygons. Default: [1, 4, 12, 24]."
          example: [1, 4, 12, 24]
        webhook_url:
          type: string
          format: uri

    RFMeshViabilityRequest:
      type: object
      description: Request body for `POST /rf/mesh/viability`.
      required: [node_ids]
      properties:
        node_ids:
          type: array
          items:
            type: string
          description: Array of registered node IDs to include in the analysis. Max 200.
          example: ["rf_node_A7", "rf_node_B3", "rf_node_C2", "rf_node_D9"]
        min_path_reliability_pct:
          type: number
          description: Minimum acceptable end-to-end path reliability. Default 99.0.
          default: 99.0
          example: 99.5
        failure_scenarios:
          type: array
          items:
            $ref: "#/components/schemas/RFFailureScenario"
          description: "Array of `{node_id, failure_probability}` scenarios to evaluate for resilience planning."

    RFOptimizePlacementRequest:
      type: object
      description: Request body for `POST /rf/optimize/placement`.
      required: [area, coverage_threshold]
      properties:
        area:
          $ref: "#/components/schemas/GeoJSONPolygon"
          description: Polygon defining the deployment area.
        coverage_threshold:
          type: number
          description: Target coverage fraction (0.0–1.0).
          example: 0.95
        existing_nodes:
          type: array
          items:
            type: string
          description: Registered node IDs to include in existing topology. Default all nodes in area.
        candidate_count:
          type: integer
          description: Number of candidate placements to return. Default 5, max 20.
          default: 5
          maximum: 20
        frequency_mhz:
          type: number
          description: Target frequency in MHz. Default 915.
          default: 915
        constraints:
          $ref: "#/components/schemas/PlacementConstraints"

    OptimizeNodePlacementRequest:
      type: object
      description: Request body for `POST /optimize/node_placement`.
      required: [area, coverage_threshold]
      properties:
        area:
          $ref: "#/components/schemas/GeoJSONPolygon"
          description: Deployment area polygon.
        coverage_threshold:
          type: number
          description: Target coverage fraction (0.0–1.0).
          example: 0.90
        existing_nodes:
          type: array
          items:
            type: string
          description: Registered existing node IDs.
        objective:
          type: string
          enum: [rf_coverage, plume_detection, flood_warning, acoustic_monitoring]
          description: "Optimization objective. Default: rf_coverage."
          default: rf_coverage
          example: "plume_detection"

    OptimizeSensorPlacementRequest:
      type: object
      description: Request body for `POST /optimize/sensor_placement`.
      required: [vertical, area, target_coverage_pct]
      properties:
        vertical:
          type: string
          description: "ZOOID vertical ID (e.g. V01, V02, V05)."
          example: "V01"
        area:
          $ref: "#/components/schemas/GeoJSONPolygon"
          description: Site or deployment area polygon.
        target_coverage_pct:
          type: number
          description: Target monitoring coverage (0.0–1.0).
          example: 0.95
        budget_nodes:
          type: integer
          description: Maximum number of additional sensor nodes to place.

    TrajectoryPredictRequest:
      type: object
      description: Request body for `POST /trajectory/predict`.
      required: [entity_id, lookahead_min]
      properties:
        entity_id:
          type: string
          description: Registered entity ID.
          example: "drone_01"
        lookahead_min:
          type: integer
          description: Prediction horizon in minutes. Max 120 min.
          example: 30
          maximum: 120
        mission_plan:
          $ref: "#/components/schemas/GeoJSONLineString"
          description: Planned route as a GeoJSON LineString — used to constrain prediction to known flight plan.

paths:

  # ─── Context API ────────────────────────────────────────────────────────────

  /context:
    get:
      operationId: getContext
      summary: Resolve SpatialState for a registered device
      description: |
        Returns the full `SpatialState` for a registered device — terrain, jurisdiction, wind vector,
        nearby entities, atmospheric stability class, and asset proximity. Enrichment runs at ingest
        time; this call returns the pre-computed state.

        **Pricing:** $0.001 / call
      tags: [Context API]
      parameters:
        - name: device_id
          in: query
          required: true
          schema:
            type: string
          description: Registered device identifier.
          example: "sensor_h2s_023"
        - name: include
          in: query
          required: false
          schema:
            type: string
          description: "Comma-separated context fields to include. Default: all. Options: terrain, jurisdiction, wind, nearby, assets, calibration."
          example: "terrain,jurisdiction,wind"
        - name: max_age_s
          in: query
          required: false
          schema:
            type: integer
            default: 300
          description: Maximum acceptable state age in seconds. Older states trigger fresh enrichment.
      responses:
        "200":
          description: SpatialState resolved successfully.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/SpatialState"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  entity_id: "sensor_h2s_023"
                  location:
                    lat: 31.9686
                    lon: -99.9018
                    alt_m: 547.2
                    h3_index: "8944c0ba857ffff"
                    indoor: null
                  time: "2026-04-21T03:14:22Z"
                  context:
                    wind_vector:
                      speed_ms: 8.2
                      bearing_deg: 312
                    terrain:
                      elevation_m: 547.2
                      slope_deg: 3.1
                      aspect_deg: 180
                    atmospheric_stability: "D"
                    nearby_entities:
                      - id: "sensor_h2s_021"
                        distance_m: 184.2
                        bearing_deg: 271
                        reading_ppm: 0.4
                      - id: "sensor_h2s_024"
                        distance_m: 412.7
                        bearing_deg: 88
                        reading_ppm: 0.1
                    jurisdiction:
                      authority: "TCEQ"
                      permit_id: "PSDTX1234"
                      limit_ppm: 10
                    asset_proximity:
                      - asset_id: "wellhead_07"
                        distance_m: 96.1
                        type: "wellhead"
                meta:
                  confidence: 0.93
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "terrain:USGS-3DEP-1m"
                    - "met:NOAA-HRRR-2026-04-21T03Z"
                    - "jurisdiction:TCEQ-2025-Q4"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 87
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Device not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /context/batch:
    get:
      operationId: getContextBatch
      summary: Batch context resolution (up to 500 devices)
      description: |
        Resolves SpatialState for up to 500 devices in a single request. Partial failures
        are represented with `null` result entries and an error sub-object.

        **Pricing:** $0.001 / device
      tags: [Context API]
      parameters:
        - name: device_ids
          in: query
          required: true
          schema:
            type: string
          description: JSON-encoded array of device IDs. Max 500 per request.
          example: '["sensor_h2s_023","sensor_h2s_024","drone_01"]'
        - name: include
          in: query
          required: false
          schema:
            type: string
          description: "Comma-separated context fields to include (see /context)."
      responses:
        "200":
          description: Batch context resolved.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      count:
                        type: integer
                        example: 3
                      succeeded:
                        type: integer
                        example: 3
                      failed:
                        type: integer
                        example: 0
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/SpatialState"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  count: 3
                  succeeded: 3
                  failed: 0
                  items:
                    - entity_id: "sensor_h2s_023"
                      location:
                        lat: 31.9686
                        lon: -99.9018
                        alt_m: 547.2
                        h3_index: "8944c0ba857ffff"
                        indoor: null
                      time: "2026-04-21T03:14:22Z"
                      context:
                        wind_vector:
                          speed_ms: 8.2
                          bearing_deg: 312
                        atmospheric_stability: "D"
                    - entity_id: "sensor_h2s_024"
                      location:
                        lat: 31.9690
                        lon: -99.9012
                        alt_m: 548.0
                        h3_index: "8944c0ba853ffff"
                        indoor: null
                      time: "2026-04-21T03:14:22Z"
                      context:
                        wind_vector:
                          speed_ms: 8.2
                          bearing_deg: 312
                        atmospheric_stability: "D"
                    - entity_id: "drone_01"
                      location:
                        lat: 31.9701
                        lon: -99.9024
                        alt_m: 612.5
                        h3_index: "8944c0ba84bffff"
                        indoor: null
                      time: "2026-04-21T03:14:22Z"
                      context:
                        wind_vector:
                          speed_ms: 8.2
                          bearing_deg: 312
                meta:
                  confidence: 0.92
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "terrain:USGS-3DEP-1m"
                    - "met:NOAA-HRRR-2026-04-21T03Z"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 142
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /nearby:
    get:
      operationId: getNearby
      summary: Proximity search
      description: |
        Returns all registered entities within a given radius of a coordinate, optionally
        filtered by type. Results are ordered by distance.

        **Pricing:** $0.001 / call
      tags: [Context API]
      parameters:
        - name: lat
          in: query
          required: true
          schema:
            type: number
            format: double
          description: WGS84 latitude of center point.
          example: 31.96
        - name: lon
          in: query
          required: true
          schema:
            type: number
            format: double
          description: WGS84 longitude of center point.
          example: -99.90
        - name: radius_m
          in: query
          required: true
          schema:
            type: integer
            maximum: 50000
          description: Search radius in meters. Max 50,000 m.
          example: 500
        - name: type
          in: query
          required: false
          schema:
            type: string
          description: "Filter by entity type. Built-in values: gas_sensor, water_sensor, rf_node, asset, personnel, drone. Any registered custom type is also accepted."
          example: "gas_sensor"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 50
            maximum: 500
          description: Maximum results to return. Default 50, max 500.
      responses:
        "200":
          description: Nearby entities returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      center:
                        type: object
                        properties:
                          lat:
                            type: number
                          lon:
                            type: number
                      radius_m:
                        type: integer
                        example: 500
                      count:
                        type: integer
                        example: 4
                      entities:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            type:
                              type: string
                            distance_m:
                              type: number
                            bearing_deg:
                              type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  center:
                    lat: 31.96
                    lon: -99.90
                  radius_m: 500
                  count: 4
                  entities:
                    - id: "sensor_h2s_023"
                      type: "gas_sensor"
                      distance_m: 142.0
                      bearing_deg: 87
                    - id: "sensor_h2s_021"
                      type: "gas_sensor"
                      distance_m: 268.4
                      bearing_deg: 271
                    - id: "sensor_h2s_024"
                      type: "gas_sensor"
                      distance_m: 401.8
                      bearing_deg: 88
                    - id: "drone_01"
                      type: "drone"
                      distance_m: 489.2
                      bearing_deg: 12
                meta:
                  confidence: 0.95
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 41
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Coordinates outside supported coverage area.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /within:
    get:
      operationId: getWithin
      summary: Polygon containment query
      description: |
        Returns all registered entities whose location falls within a given GeoJSON polygon.
        Used for zone-based queries — "which sensors are inside this permit boundary?"

        **Pricing:** $0.001 / call (+$0.001/entity if include_context=true)
      tags: [Context API]
      parameters:
        - name: geometry
          in: query
          required: true
          schema:
            type: string
          description: URL-encoded GeoJSON Polygon or MultiPolygon.
        - name: type
          in: query
          required: false
          schema:
            type: string
          description: "Entity type filter. Same built-in values as /nearby (gas_sensor, water_sensor, rf_node, asset, personnel, drone), plus any registered custom type."
        - name: include_context
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: If true, includes full SpatialState for each result (billed at $0.001/entity).
      responses:
        "200":
          description: Entities within polygon returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      count:
                        type: integer
                        example: 3
                      entities:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            type:
                              type: string
                            location:
                              $ref: "#/components/schemas/Location"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  count: 3
                  entities:
                    - id: "sensor_h2s_023"
                      type: "gas_sensor"
                      location:
                        lat: 31.9686
                        lon: -99.9018
                        alt_m: 547.2
                        h3_index: "8944c0ba857ffff"
                        indoor: null
                    - id: "sensor_h2s_024"
                      type: "gas_sensor"
                      location:
                        lat: 31.9690
                        lon: -99.9012
                        alt_m: 548.0
                        h3_index: "8944c0ba853ffff"
                        indoor: null
                    - id: "wellhead_07"
                      type: "asset"
                      location:
                        lat: 31.9682
                        lon: -99.9020
                        alt_m: 547.0
                        h3_index: "8944c0ba85bffff"
                        indoor: null
                meta:
                  confidence: 0.97
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 38
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /jurisdiction:
    get:
      operationId: getJurisdiction
      summary: Jurisdiction resolution for any coordinate
      description: |
        Returns all applicable regulatory jurisdictions and permit obligations for a given
        coordinate — air quality authority, watershed governance, noise ordinance, and
        cross-boundary relationships.

        **Pricing:** $0.0005 / call (free when device_id is passed to /context)
      tags: [Context API]
      parameters:
        - name: lat
          in: query
          required: false
          schema:
            type: number
            format: double
          description: WGS84 latitude. Required unless device_id is provided.
          example: 31.9686
        - name: lon
          in: query
          required: false
          schema:
            type: number
            format: double
          description: WGS84 longitude. Required unless device_id is provided.
          example: -99.9018
        - name: device_id
          in: query
          required: false
          schema:
            type: string
          description: Alternative to lat/lon — uses the device's registered coordinates.
        - name: domains
          in: query
          required: false
          schema:
            type: string
          description: "Comma-separated domain filter. Options: air, water, noise, land, fire. Default: all."
          example: "air,water"
      responses:
        "200":
          description: Jurisdiction data returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      location:
                        type: object
                        properties:
                          lat:
                            type: number
                          lon:
                            type: number
                      jurisdiction:
                        type: object
                        description: Keyed by domain (air_quality, water, noise, etc.).
                        additionalProperties:
                          type: object
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  location:
                    lat: 31.9686
                    lon: -99.9018
                  jurisdiction:
                    air_quality:
                      authority: "TCEQ"
                      permit_id: "PSDTX1234"
                      limit_ppm: 10
                    water:
                      authority: "TCEQ"
                      watershed_id: "basin_colorado_mid"
                meta:
                  confidence: 0.99
                  model_version: "jurisdiction-v2.3.0"
                  calibration_age_days: 12
                  site_calibrated: false
                  provenance:
                    - "jurisdiction:TCEQ-2025-Q4"
                    - "watershed:USGS-WBD-2025"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 33
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Coordinates outside supported coverage area.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Simulate API ────────────────────────────────────────────────────────────

  /simulate/plume:
    post:
      operationId: simulatePlume
      summary: Atmospheric dispersion simulation
      description: |
        Runs an atmospheric dispersion model for a point or area source. Tier 1 uses Gaussian
        plume (fast screening, <8 s). Tier 2 uses AERMOD-style regulatory-grade modeling
        (15–60 s). Tier 3 uses Lagrangian particle tracking for non-steady-state scenarios
        (minutes). Returns concentration isopleths, predicted readings at registered sensor
        locations, and affected zone classifications.

        **Pricing:** $0.05–$0.25 / run depending on tier.
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source, duration_min]
              properties:
                source:
                  type: object
                  required: [lat, lon, emission_rate_gs]
                  description: "`{lat, lon, alt_m, emission_rate_gs, stack_height_m}` — emission source geometry."
                  properties:
                    lat:
                      type: number
                      example: 31.9642
                    lon:
                      type: number
                      example: -99.9035
                    alt_m:
                      type: number
                      example: 545
                    emission_rate_gs:
                      type: number
                      description: Emission rate in grams per second.
                      example: 2.4
                    stack_height_m:
                      type: number
                      description: Effective stack height in metres. Default 0 (ground level).
                      example: 12
                wind:
                  type: object
                  description: "`{speed_ms, bearing_deg}` — overrides live met data if provided."
                  properties:
                    speed_ms:
                      type: number
                      example: 8.2
                    bearing_deg:
                      type: number
                      example: 312
                stability_class:
                  type: string
                  enum: [A, B, C, D, E, F]
                  description: Pasquill-Gifford atmospheric stability class A–F. Auto-resolved from met data if omitted.
                  example: "D"
                duration_min:
                  type: integer
                  description: Simulation duration in minutes. Max 720 for Tier 1, 480 for Tier 2.
                  example: 60
                tier:
                  type: integer
                  enum: [1, 2, 3]
                  description: "1 (Gaussian, $0.05), 2 (AERMOD, $0.25), 3 (Lagrangian, $0.50). Default: 1."
                  default: 1
                species:
                  type: string
                  description: "Chemical species for species-specific dispersion coefficients (e.g. H2S, CH4, NO2, PM25). Default: generic passive scalar."
                  example: "H2S"
                output_receptors:
                  type: array
                  items:
                    type: string
                  description: Array of device IDs to include predicted concentrations for in the response.
                  example: ["sensor_h2s_021", "sensor_h2s_023", "sensor_h2s_024"]
                webhook_url:
                  type: string
                  format: uri
                  description: Override account-level webhook for this job only.
            example:
              source:
                lat: 31.9642
                lon: -99.9035
                alt_m: 545
                emission_rate_gs: 2.4
                stack_height_m: 12
              wind:
                speed_ms: 8.2
                bearing_deg: 312
              stability_class: "D"
              duration_min: 60
              tier: 2
              species: "H2S"
              output_receptors:
                - "sensor_h2s_021"
                - "sensor_h2s_023"
                - "sensor_h2s_024"
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a3f9k8qmzn7p"
                  type: "simulate/plume"
                  status: "queued"
                  eta_seconds: 8
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/plume/stream:
    post:
      operationId: streamPlume
      summary: Live plume tracking via Server-Sent Events
      description: |
        Opens a Server-Sent Events (SSE) stream that delivers a running plume
        simulation in real time. The client POSTs a `PlumeStreamRequest` with
        `Accept: text/event-stream`; the server responds `200 OK` with
        `Content-Type: text/event-stream` and emits a sequence of named events
        until the simulation completes or the client disconnects.

        **Event types** (the SSE `event:` field):

        - `tick` — heartbeat with simulation clock and wind vector, emitted
          every `tick_interval_s` seconds
        - `concentration` — updated isopleth polygons and per-receptor
          concentrations, emitted on every tick after the field stabilises
        - `alert` — emitted when an `alert_thresholds` value is crossed at
          any receptor or isopleth
        - `end` — terminal event with run summary; clients should stop
          reconnecting after receiving this

        Every event carries a stable `id:` field. Clients that drop the
        connection should reconnect with `Last-Event-ID` to resume from the
        last event seen. The official SDKs (`streamPlume` in TypeScript,
        `stream_plume` in Python) implement this reconnect-with-backoff
        behaviour automatically.

        **Availability:** Site Calibration plans only.

        **Pricing:** $0.25 / run, billed once on `end`.
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlumeStreamRequest"
            example:
              source:
                lat: 31.9642
                lon: -99.9035
                alt_m: 545
                emission_rate_gs: 2.4
                stack_height_m: 12
              wind:
                speed_ms: 8.2
                bearing_deg: 312
              stability_class: "D"
              duration_min: 60
              species: "H2S"
              output_receptors:
                - "sensor_h2s_021"
                - "sensor_h2s_023"
                - "sensor_h2s_024"
              tick_interval_s: 5
              alert_thresholds:
                warn: 1.0
                action: 5.0
                evacuate: 10.0
      responses:
        "200":
          description: |
            SSE stream opened. The response body is a `text/event-stream`
            sequence of events; the schema below describes the JSON `data:`
            payload of each named event.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            text/event-stream:
              schema:
                description: |
                  Server-Sent Events stream. Each event line block uses the SSE
                  wire format (`event: <name>\nid: <n>\ndata: <json>\n\n`).
                  Examples below show full SSE event blocks as strings; the JSON
                  payload of `data:` for each named event matches one of the
                  per-event schemas listed in `oneOf`.
                oneOf:
                  - type: string
                    description: Raw SSE event block (`event:`/`id:`/`data:` lines).
                  - $ref: "#/components/schemas/PlumeStreamTick"
                  - $ref: "#/components/schemas/PlumeStreamConcentration"
                  - $ref: "#/components/schemas/PlumeStreamAlert"
                  - $ref: "#/components/schemas/PlumeStreamEnd"
              examples:
                tick:
                  summary: tick event
                  value: |
                    event: tick
                    id: 1
                    data: {"sim_time_s":5,"wall_time":"2026-04-21T03:14:27Z","progress":0.0014,"wind_vector":{"speed_ms":8.2,"bearing_deg":312}}
                concentration:
                  summary: concentration event
                  value: |
                    event: concentration
                    id: 2
                    data: {"sim_time_s":10,"isopleths":[{"concentration_ppm":10,"polygon":{"type":"Polygon","coordinates":[]}}],"receptors":[{"device_id":"sensor_h2s_023","concentration_ppm":4.7}],"max_concentration_ppm":12.4}
                alert:
                  summary: alert event
                  value: |
                    event: alert
                    id: 9
                    data: {"sim_time_s":45,"threshold":"action","threshold_ppm":5.0,"observed_ppm":6.2,"device_id":"sensor_h2s_023","location":{"lat":31.9686,"lon":-99.9018}}
                end:
                  summary: end event
                  value: |
                    event: end
                    id: 720
                    data: {"sim_time_s":3600,"reason":"completed","summary":{"ticks_emitted":720,"alerts_emitted":3,"max_concentration_ppm":58.1,"job_id":"job_01hx2a3f9k8qmzn7p"}}
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: Streaming is not available on the caller's plan (Site Calibration only).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit or concurrent-stream limit exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/flood:
    post:
      operationId: simulateFlood
      summary: Flood and hydrological simulation
      description: |
        Runs DEM-based watershed delineation, SCS Curve Number rainfall-runoff modeling,
        kinematic wave channel routing, and 2D inundation mapping. Returns flood extent
        polygons at multiple return intervals, time-to-crest forecasts, and sensors predicted
        to be inundated.

        Either `watershed_id` or `area` is required.

        **Pricing:** $0.10 / run
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [precip_mm, duration_hr]
              properties:
                watershed_id:
                  type: string
                  description: Registered watershed identifier. Required unless `area` is provided.
                  example: "basin_colorado_mid"
                area:
                  $ref: "#/components/schemas/GeoJSONPolygon"
                  description: Polygon to delineate watershed from. Required unless `watershed_id` is provided.
                precip_mm:
                  type: number
                  description: Total precipitation in mm.
                  example: 85
                duration_hr:
                  type: number
                  description: Precipitation duration in hours.
                  example: 3
                return_periods:
                  type: array
                  items:
                    type: integer
                  description: "Return intervals for inundation mapping (years). Default: [2, 10, 100]."
                  example: [10, 100]
                antecedent_moisture:
                  type: string
                  enum: [dry, normal, wet]
                  description: Soil moisture condition. Default auto-resolved from recent sensor data.
                webhook_url:
                  type: string
                  format: uri
            example:
              watershed_id: "basin_colorado_mid"
              precip_mm: 85
              duration_hr: 3
              return_periods:
                - 10
                - 100
              antecedent_moisture: "wet"
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7kflood01"
                  type: "simulate/flood"
                  status: "queued"
                  eta_seconds: 22
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7kflood01"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/rf_coverage:
    post:
      operationId: simulateRFCoverage
      summary: Single-node RF coverage simulation
      description: |
        Runs the ITM/Longley-Rice terrain propagation model for a single RF node. Returns a
        coverage probability polygon at the specified RSSI threshold, plus a raster heatmap
        and link budget summary. Either `node_id` or `lat/lon/alt_m` is required.

        **Pricing:** $0.08 / run
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateRFCoverageRequest"
            example:
              node_id: "rf_node_A7"
              frequency_mhz: 915
              tx_power_dbm: 20
              rssi_threshold_dbm: -95
              antenna_height_m: 6
              model: "itm"
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7krf0001"
                  type: "simulate/rf_coverage"
                  status: "queued"
                  eta_seconds: 11
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7krf0001"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/noise:
    post:
      operationId: simulateNoise
      summary: Noise propagation simulation
      description: |
        Runs ISO 9613-2 octave-band outdoor sound propagation from a point or line source.
        Accounts for geometric spreading, atmospheric absorption, ground effect, barrier
        insertion loss, and vegetation attenuation. Returns noise contour polygons (isodB
        lines) and exceedance flags at registered receptor locations.

        Either `source_id` or a source geometry must be provided.

        **Pricing:** $0.08 / run
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateNoiseRequest"
            example:
              source_id: "compressor_C4"
              octave_bands_db:
                - 82
                - 86
                - 91
                - 95
                - 93
                - 88
                - 84
                - 79
              contour_levels_dba:
                - 45
                - 55
                - 65
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7knoise01"
                  type: "simulate/noise"
                  status: "queued"
                  eta_seconds: 14
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7knoise01"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/thermal:
    post:
      operationId: simulateThermal
      summary: Thermal / heat diffusion simulation
      description: |
        Models heat propagation from industrial sources, fire events, or equipment failures.
        Returns time-to-temperature-threshold at monitored locations and hot zone polygons.

        **Pricing:** $0.08 / run
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateThermalRequest"
            example:
              source:
                lat: 31.9642
                lon: -99.9035
                power_kw: 850
                temperature_c: 240
              duration_min: 120
              threshold_c:
                - 40
                - 60
                - 80
              output_receptors:
                - "sensor_temp_07"
                - "sensor_temp_12"
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7kthermal"
                  type: "simulate/thermal"
                  status: "queued"
                  eta_seconds: 12
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7kthermal"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /simulate/fire:
    post:
      operationId: simulateFire
      summary: Fire spread simulation
      description: |
        Models wildfire spread using terrain, fuel model, and weather conditions. Returns fire
        perimeter polygons at specified time intervals, rate-of-spread vectors, and smoke plume
        trajectory (linked to `/simulate/plume` for full smoke transport).

        **Pricing:** $0.15 / run
      tags: [Simulate API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateFireRequest"
            example:
              ignition_point:
                type: "Point"
                coordinates:
                  - -119.42
                  - 34.21
              fuel_model: "GR2"
              weather:
                wind_speed_ms: 8.3
                wind_bearing_deg: 225
                rh_pct: 12
                temp_c: 38
              duration_hr: 24
              output_intervals_hr:
                - 1
                - 4
                - 12
                - 24
      responses:
        "202":
          description: Simulation job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7kfire001"
                  type: "simulate/fire"
                  status: "queued"
                  eta_seconds: 42
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7kfire001"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: Simulation queue full.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── RF API ──────────────────────────────────────────────────────────────────

  /rf/coverage:
    get:
      operationId: getRFCoverage
      summary: Coverage polygon for a registered node
      description: |
        Returns the coverage probability polygon for a registered RF node at a given RSSI
        threshold. Cached per node until terrain or node configuration changes.

        **Pricing:** $0.08 / call
      tags: [RF API]
      parameters:
        - name: node
          in: query
          required: true
          schema:
            type: string
          description: Registered RF node ID.
          example: "rf_node_A7"
        - name: threshold_dbm
          in: query
          required: false
          schema:
            type: number
            default: -90
          description: RSSI threshold in dBm. Default -90 dBm.
          example: -92
      responses:
        "200":
          description: Coverage polygon returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      node_id:
                        type: string
                      threshold_dbm:
                        type: number
                      coverage_polygon:
                        $ref: "#/components/schemas/GeoJSONPolygon"
                      coverage_area_km2:
                        type: number
                      max_range_m:
                        type: number
                      cached:
                        type: boolean
                      cache_age_min:
                        type: integer
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  node_id: "rf_node_A7"
                  threshold_dbm: -92
                  coverage_polygon:
                    type: "Polygon"
                    coordinates:
                      - - - -99.911
                          - 31.962
                        - - -99.901
                          - 31.962
                        - - -99.901
                          - 31.972
                        - - -99.911
                          - 31.972
                        - - -99.911
                          - 31.962
                  coverage_area_km2: 4.12
                  max_range_m: 1820
                  cached: true
                  cache_age_min: 17
                meta:
                  confidence: 0.91
                  model_version: "rf-itm-v3.2.0"
                  calibration_age_days: 22
                  site_calibrated: true
                  provenance:
                    - "terrain:USGS-3DEP-1m"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 64
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Node not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /rf/link:
    get:
      operationId: getRFLink
      summary: Point-to-point RF link budget
      description: |
        Returns the link budget between two registered nodes — predicted RSSI, fade margin,
        Fresnel zone clearance, and obstruction analysis. Returns a pass/fail recommendation
        given the specified reliability threshold.

        **Pricing:** $0.03 / call
      tags: [RF API]
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
          description: Source node ID.
          example: "rf_node_A7"
        - name: to
          in: query
          required: true
          schema:
            type: string
          description: Target node ID.
          example: "rf_node_B3"
        - name: reliability_pct
          in: query
          required: false
          schema:
            type: number
            default: 99.0
          description: Required link reliability percentage. Default 99.0.
      responses:
        "200":
          description: Link budget calculated.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      from:
                        type: string
                      to:
                        type: string
                      distance_m:
                        type: number
                      predicted_rssi_dbm:
                        type: number
                      fade_margin_db:
                        type: number
                      fresnel_clearance_pct:
                        type: number
                        description: Percentage of first Fresnel zone that is clear.
                      obstructions:
                        type: array
                        items:
                          type: object
                      link_viable:
                        type: boolean
                      reliability_at_threshold:
                        type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  from: "rf_node_A7"
                  to: "rf_node_B3"
                  distance_m: 1842.0
                  predicted_rssi_dbm: -78.4
                  fade_margin_db: 11.6
                  fresnel_clearance_pct: 84.0
                  obstructions: []
                  link_viable: true
                  reliability_at_threshold: 99.4
                meta:
                  confidence: 0.94
                  model_version: "rf-itm-v3.2.0"
                  calibration_age_days: 22
                  site_calibrated: true
                  provenance:
                    - "terrain:USGS-3DEP-1m"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 56
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /rf/gaps:
    get:
      operationId: getRFGaps
      summary: Coverage gap heatmap within an area
      description: |
        Identifies coverage gaps within a defined area given existing node topology. Returns a
        GeoJSON heatmap of gap severity and a list of H3 cells with insufficient coverage.

        **Pricing:** $0.05 / call
      tags: [RF API]
      parameters:
        - name: area
          in: query
          required: true
          schema:
            type: string
          description: URL-encoded GeoJSON Polygon defining the area to analyze.
        - name: threshold_dbm
          in: query
          required: false
          schema:
            type: number
            default: -90
          description: RSSI below which a cell is considered a gap. Default -90 dBm.
        - name: node_ids
          in: query
          required: false
          schema:
            type: string
          description: Comma-separated node IDs to analyze against. Default all registered nodes in area.
      responses:
        "200":
          description: Coverage gap analysis returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      coverage_pct:
                        type: number
                        example: 67.4
                      gap_pct:
                        type: number
                        example: 32.6
                      gap_heatmap:
                        type: object
                        description: GeoJSON heatmap FeatureCollection.
                      gap_cells_h3:
                        type: array
                        items:
                          type: string
                      worst_gap_location:
                        type: object
                        properties:
                          lat:
                            type: number
                          lon:
                            type: number
                          best_rssi_dbm:
                            type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  coverage_pct: 67.4
                  gap_pct: 32.6
                  gap_heatmap:
                    type: "FeatureCollection"
                    features: []
                  gap_cells_h3:
                    - "8944c0ba8a3ffff"
                    - "8944c0ba8a7ffff"
                    - "8944c0ba8abffff"
                  worst_gap_location:
                    lat: 31.9712
                    lon: -99.8945
                    best_rssi_dbm: -101.4
                meta:
                  confidence: 0.88
                  model_version: "rf-itm-v3.2.0"
                  calibration_age_days: 22
                  site_calibrated: true
                  provenance:
                    - "terrain:USGS-3DEP-1m"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 192
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /rf/mesh/viability:
    post:
      operationId: rfMeshViability
      summary: Full mesh connectivity matrix
      description: |
        Computes the full link budget matrix for a mesh network — every node-to-node pair,
        with failure probability based on calibrated path loss models. Returns a connectivity
        matrix, mesh reliability score, single-points-of-failure, and failure scenario analysis.

        Async job; typical completion 15–40 seconds for networks up to 200 nodes.

        **Pricing:** $0.15 / run
      tags: [RF API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RFMeshViabilityRequest"
            example:
              node_ids:
                - "rf_node_A7"
                - "rf_node_B3"
                - "rf_node_C2"
                - "rf_node_D9"
              min_path_reliability_pct: 99.5
              failure_scenarios:
                - node_id: "rf_node_A7"
                  failure_probability: 0.05
      responses:
        "202":
          description: Mesh viability job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7kmesh001"
                  type: "rf/mesh/viability"
                  status: "queued"
                  eta_seconds: 28
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7kmesh001"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /rf/optimize/placement:
    post:
      operationId: rfOptimizePlacement
      summary: Node placement optimizer for RF coverage
      description: |
        Given existing mesh topology, terrain, and coverage requirements, returns the top-N
        candidate locations that maximize marginal coverage gain.

        **Pricing:** $1.00–$5.00 / run
      tags: [RF API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RFOptimizePlacementRequest"
            example:
              area:
                type: "Polygon"
                coordinates:
                  - - - -99.92
                        - 31.95
                      - - -99.88
                        - 31.95
                      - - -99.88
                        - 31.99
                      - - -99.92
                        - 31.99
                      - - -99.92
                        - 31.95
              coverage_threshold: 0.95
              existing_nodes:
                - "rf_node_A7"
                - "rf_node_B3"
              candidate_count: 5
              frequency_mhz: 915
              constraints:
                min_distance_from_edge_m: 50
                require_road_access: true
      responses:
        "202":
          description: Placement optimization job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7krfopt01"
                  type: "rf/optimize/placement"
                  status: "queued"
                  eta_seconds: 180
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7krfopt01"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Optimize API ─────────────────────────────────────────────────────────────

  /optimize/node_placement:
    post:
      operationId: optimizeNodePlacement
      summary: General node placement optimizer
      description: |
        Generalizes RF placement to any monitoring objective — air quality sensor spacing
        (accounting for plume physics), flood gauge placement (watershed coverage), or
        acoustic monitor placement (source coverage). Returns ranked candidate locations with
        objective-specific marginal gain scores.

        **Pricing:** $1.00–$5.00 / run
      tags: [Optimize API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OptimizeNodePlacementRequest"
            example:
              area:
                type: "Polygon"
                coordinates:
                  - - - -99.92
                        - 31.95
                      - - -99.88
                        - 31.95
                      - - -99.88
                        - 31.99
                      - - -99.92
                        - 31.99
                      - - -99.92
                        - 31.95
              coverage_threshold: 0.90
              existing_nodes:
                - "sensor_h2s_021"
                - "sensor_h2s_023"
              objective: "plume_detection"
      responses:
        "202":
          description: Node placement job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7knode001"
                  type: "optimize/node_placement"
                  status: "queued"
                  eta_seconds: 120
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7knode001"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /optimize/sensor_placement:
    post:
      operationId: optimizeSensorPlacement
      summary: Vertical-aware sensor placement optimization
      description: |
        Recommends sensor placement for a given ZOOID vertical — accounts for the vertical's
        specific measurement physics, regulatory requirements, and deployment constraints.

        **Pricing:** $2.00–$5.00 / run
      tags: [Optimize API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OptimizeSensorPlacementRequest"
            example:
              vertical: "V01"
              area:
                type: "Polygon"
                coordinates:
                  - - - -99.92
                        - 31.95
                      - - -99.88
                        - 31.95
                      - - -99.88
                        - 31.99
                      - - -99.92
                        - 31.99
                      - - -99.92
                        - 31.95
              target_coverage_pct: 0.95
              budget_nodes: 8
      responses:
        "202":
          description: Sensor placement job accepted.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AsyncJobResponse"
              example:
                ok: true
                job:
                  id: "job_01hx2a4q7ksensor01"
                  type: "optimize/sensor_placement"
                  status: "queued"
                  eta_seconds: 240
                  poll_url: "https://api.geog.ai/v1/jobs/job_01hx2a4q7ksensor01"
                  webhook_delivered: false
                meta:
                  request_id: "req_01hx29sknd4bqyt8r"
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /coverage:
    get:
      operationId: getCoverage
      summary: Coverage analysis for an area
      description: |
        Returns the current monitoring coverage fraction for a given area and sensor type.
        Coverage is defined by the spatial footprint of registered sensors given their
        detection physics.

        **Pricing:** $0.01 / call
      tags: [Optimize API]
      parameters:
        - name: area
          in: query
          required: true
          schema:
            type: string
          description: URL-encoded GeoJSON polygon defining the area of interest.
        - name: type
          in: query
          required: true
          schema:
            type: string
          description: Sensor type to assess coverage for.
          example: "gas_sensor"
      responses:
        "200":
          description: Coverage analysis returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      sensor_type:
                        type: string
                      area_km2:
                        type: number
                      covered_km2:
                        type: number
                      coverage_pct:
                        type: number
                        example: 65.8
                      active_sensors:
                        type: integer
                      coverage_polygon:
                        $ref: "#/components/schemas/GeoJSONPolygon"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  sensor_type: "gas_sensor"
                  area_km2: 12.4
                  covered_km2: 8.16
                  coverage_pct: 65.8
                  active_sensors: 14
                  coverage_polygon:
                    type: "Polygon"
                    coordinates:
                      - - - -99.911
                          - 31.962
                        - - -99.901
                          - 31.962
                        - - -99.901
                          - 31.972
                        - - -99.911
                          - 31.972
                        - - -99.911
                          - 31.962
                meta:
                  confidence: 0.92
                  model_version: "coverage-v1.4.0"
                  calibration_age_days: 18
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 72
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /gaps:
    get:
      operationId: getGaps
      summary: Monitoring gap analysis for an area
      description: |
        Identifies monitoring gaps within a defined area — H3 cells where no sensor provides
        adequate coverage given detection physics. Returns a GeoJSON gap heatmap and summary
        statistics.

        **Pricing:** $0.01 / call
      tags: [Optimize API]
      parameters:
        - name: area
          in: query
          required: true
          schema:
            type: string
          description: URL-encoded GeoJSON polygon defining the area of interest.
        - name: threshold_m
          in: query
          required: false
          schema:
            type: number
            default: 500
          description: Maximum acceptable distance from any sensor in metres. Default 500 m.
          example: 400
        - name: type
          in: query
          required: false
          schema:
            type: string
          description: Sensor type filter.
      responses:
        "200":
          description: Gap analysis returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      gap_pct:
                        type: number
                        example: 34.2
                      gap_heatmap:
                        type: object
                        description: GeoJSON FeatureCollection — severity-colored H3 cells.
                      gap_cells_h3:
                        type: array
                        items:
                          type: string
                      largest_gap_area_km2:
                        type: number
                      largest_gap_centroid:
                        type: object
                        properties:
                          lat:
                            type: number
                          lon:
                            type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  gap_pct: 34.2
                  gap_heatmap:
                    type: "FeatureCollection"
                    features: []
                  gap_cells_h3:
                    - "8944c0ba8a3ffff"
                    - "8944c0ba8a7ffff"
                  largest_gap_area_km2: 1.8
                  largest_gap_centroid:
                    lat: 31.9714
                    lon: -99.8946
                meta:
                  confidence: 0.90
                  model_version: "coverage-v1.4.0"
                  calibration_age_days: 18
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 96
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Trajectory API ───────────────────────────────────────────────────────────

  /trajectory:
    get:
      operationId: getTrajectory
      summary: Trajectory history for a moving entity
      description: |
        Returns the stored trajectory (sequence of positions with timestamps and uncertainty
        bounds) for a registered moving entity over a specified time window.

        **Pricing:** $0.002 / call
      tags: [Trajectory API]
      parameters:
        - name: entity_id
          in: query
          required: true
          schema:
            type: string
          description: Registered entity ID (drone, vehicle, or personnel).
          example: "drone_01"
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Start of time window (ISO 8601).
          example: "2026-04-21T02:00:00Z"
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: End of time window (ISO 8601). Max window 30 days.
          example: "2026-04-21T03:00:00Z"
        - name: resolution_s
          in: query
          required: false
          schema:
            type: integer
          description: Temporal resolution of returned positions in seconds. Default raw (all recorded positions).
      responses:
        "200":
          description: Trajectory returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      entity_id:
                        type: string
                      window:
                        type: object
                        properties:
                          from:
                            type: string
                            format: date-time
                          to:
                            type: string
                            format: date-time
                      point_count:
                        type: integer
                      positions:
                        type: array
                        items:
                          type: object
                          properties:
                            time:
                              type: string
                              format: date-time
                            lat:
                              type: number
                            lon:
                              type: number
                            alt_m:
                              type: number
                            uncertainty_m:
                              type: number
                      geojson_linestring:
                        type: object
                        description: GeoJSON LineString for rendering.
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  entity_id: "drone_01"
                  window:
                    from: "2026-04-21T02:00:00Z"
                    to: "2026-04-21T03:00:00Z"
                  point_count: 3
                  positions:
                    - time: "2026-04-21T02:00:00Z"
                      lat: 31.9701
                      lon: -99.9024
                      alt_m: 612.5
                      uncertainty_m: 1.2
                    - time: "2026-04-21T02:30:00Z"
                      lat: 31.9712
                      lon: -99.9018
                      alt_m: 615.0
                      uncertainty_m: 1.4
                    - time: "2026-04-21T03:00:00Z"
                      lat: 31.9724
                      lon: -99.9011
                      alt_m: 618.2
                      uncertainty_m: 1.6
                  geojson_linestring:
                    type: "LineString"
                    coordinates:
                      - - -99.9024
                        - 31.9701
                        - 612.5
                      - - -99.9018
                        - 31.9712
                        - 615.0
                      - - -99.9011
                        - 31.9724
                        - 618.2
                meta:
                  confidence: 0.96
                  model_version: "trajectory-v2.0.1"
                  calibration_age_days: 30
                  site_calibrated: true
                  provenance:
                    - "telemetry:drone-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 49
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Entity not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /trajectory/predict:
    post:
      operationId: trajectoryPredict
      summary: Trajectory prediction / dead reckoning
      description: |
        Predicts the future trajectory of a moving entity given current position, velocity,
        and historical movement patterns. Returns a predicted path with expanding uncertainty
        bounds. Used for drone flight path prediction, fleet routing optimization, and
        personnel zone arrival forecasting.

        **Pricing:** $0.005 / call
      tags: [Trajectory API]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TrajectoryPredictRequest"
            example:
              entity_id: "drone_01"
              lookahead_min: 30
              mission_plan:
                type: "LineString"
                coordinates:
                  - - -99.9024
                    - 31.9701
                  - - -99.8980
                    - 31.9755
                  - - -99.8932
                    - 31.9810
      responses:
        "200":
          description: Trajectory prediction returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      entity_id:
                        type: string
                      predicted_positions:
                        type: array
                        items:
                          type: object
                          properties:
                            time:
                              type: string
                              format: date-time
                            lat:
                              type: number
                            lon:
                              type: number
                            alt_m:
                              type: number
                            uncertainty_m:
                              type: number
                      predicted_path:
                        type: object
                        description: GeoJSON LineString.
                      uncertainty_corridor:
                        type: object
                        description: GeoJSON Polygon — expanding uncertainty bounds.
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  entity_id: "drone_01"
                  predicted_positions:
                    - time: "2026-04-21T03:10:00Z"
                      lat: 31.9740
                      lon: -99.9000
                      alt_m: 620.0
                      uncertainty_m: 4.2
                    - time: "2026-04-21T03:20:00Z"
                      lat: 31.9760
                      lon: -99.8980
                      alt_m: 622.4
                      uncertainty_m: 8.7
                    - time: "2026-04-21T03:30:00Z"
                      lat: 31.9780
                      lon: -99.8960
                      alt_m: 624.1
                      uncertainty_m: 14.1
                  predicted_path:
                    type: "LineString"
                    coordinates:
                      - - -99.9000
                        - 31.9740
                        - 620.0
                      - - -99.8980
                        - 31.9760
                        - 622.4
                      - - -99.8960
                        - 31.9780
                        - 624.1
                  uncertainty_corridor:
                    type: "Polygon"
                    coordinates:
                      - - - -99.9004
                          - 31.9738
                        - - -99.8956
                          - 31.9778
                        - - -99.8964
                          - 31.9784
                        - - -99.9008
                          - 31.9742
                        - - -99.9004
                          - 31.9738
                meta:
                  confidence: 0.84
                  model_version: "trajectory-v2.0.1"
                  calibration_age_days: 30
                  site_calibrated: true
                  provenance:
                    - "telemetry:drone-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 102
        "400":
          description: Invalid parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Entity not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Graph API ───────────────────────────────────────────────────────────────

  /graph/influence:
    get:
      operationId: getGraphInfluence
      summary: Downstream influence spread from a node
      description: |
        Returns all nodes in the sensor graph that are "downstream" of a source event —
        entities likely to be affected if the source produces an event. Edge weights reflect
        propagation physics (plume transport, fluid flow, RF signal paths). Results are
        ordered by predicted arrival time and estimated impact magnitude.

        **Pricing:** $0.005 / call
      tags: [Graph API]
      parameters:
        - name: source_id
          in: query
          required: true
          schema:
            type: string
          description: Source node ID (device, asset, or event location).
          example: "sensor_h2s_023"
        - name: edge_type
          in: query
          required: false
          schema:
            type: string
            enum: [atmospheric, fluid, rf, causal]
            default: causal
          description: "Edge type to traverse. atmospheric = plume, fluid = pipe/stream, rf = signal, causal = all. Default: causal."
        - name: max_hops
          in: query
          required: false
          schema:
            type: integer
            default: 5
          description: Maximum graph traversal depth. Default 5.
        - name: time_horizon_min
          in: query
          required: false
          schema:
            type: integer
          description: Restrict to nodes reachable within this time in minutes. Default unlimited.
      responses:
        "200":
          description: Downstream influenced entities returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      source:
                        type: string
                      downstream_nodes:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            arrival_min:
                              type: number
                            edge_type:
                              type: string
                            impact_score:
                              type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  source: "sensor_h2s_023"
                  downstream_nodes:
                    - id: "sensor_h2s_024"
                      arrival_min: 1.2
                      edge_type: "atmospheric"
                      impact_score: 0.78
                    - id: "personnel_zone_C"
                      arrival_min: 4.6
                      edge_type: "atmospheric"
                      impact_score: 0.41
                    - id: "wellhead_07"
                      arrival_min: 7.9
                      edge_type: "atmospheric"
                      impact_score: 0.22
                meta:
                  confidence: 0.87
                  model_version: "graph-influence-v1.6.0"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "graph:facility-2026-04-21"
                    - "met:NOAA-HRRR-2026-04-21T03Z"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 88
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Entity not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /graph/evacuate:
    get:
      operationId: getGraphEvacuate
      summary: Evacuation routing given current hazard state
      description: |
        Returns optimal evacuation routes from specified locations to safe zones, routing around
        active hazard polygons (from live plume, fire, or flood simulations). Uses pgRouting on
        the facility graph with real-time hazard avoidance.

        **Pricing:** $0.01 / call
      tags: [Graph API]
      parameters:
        - name: from_ids
          in: query
          required: true
          schema:
            type: string
          description: Comma-separated entity or zone IDs to evacuate from (personnel IDs, zone IDs).
          example: "personnel_zone_C,personnel_zone_D"
        - name: to_ids
          in: query
          required: false
          schema:
            type: string
          description: Comma-separated target safe zone IDs. Default all registered safe zones in the facility.
        - name: avoid_job_ids
          in: query
          required: false
          schema:
            type: string
          description: Comma-separated simulation job IDs whose result polygons to treat as impassable (live plume, flood, fire footprints).
          example: "job_01hxyz"
        - name: site_id
          in: query
          required: false
          schema:
            type: string
          description: Site context for indoor/outdoor routing graph selection.
          example: "wellpad_07"
      responses:
        "200":
          description: Evacuation routes returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      routes:
                        type: array
                        items:
                          type: object
                          properties:
                            entity_id:
                              type: string
                            route:
                              type: object
                              description: GeoJSON LineString.
                            distance_m:
                              type: number
                            eta_min:
                              type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  routes:
                    - entity_id: "personnel_zone_C"
                      route:
                        type: "LineString"
                        coordinates:
                          - - -99.9018
                            - 31.9686
                          - - -99.9020
                            - 31.9692
                          - - -99.9028
                            - 31.9700
                          - - -99.9036
                            - 31.9710
                      distance_m: 412.0
                      eta_min: 5.4
                    - entity_id: "personnel_zone_D"
                      route:
                        type: "LineString"
                        coordinates:
                          - - -99.9012
                            - 31.9690
                          - - -99.9015
                            - 31.9696
                          - - -99.9024
                            - 31.9706
                          - - -99.9036
                            - 31.9710
                      distance_m: 478.0
                      eta_min: 6.2
                meta:
                  confidence: 0.93
                  model_version: "graph-evac-v2.1.0"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "graph:facility-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 71
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Entity or zone not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /graph/upstream:
    get:
      operationId: getGraphUpstream
      summary: Upstream sensor identification for a gauge
      description: |
        For a given stream gauge or sensor, identifies all upstream contributing sensors
        in the watershed or pipeline network, ordered by distance and travel time.

        **Pricing:** $0.003 / call
      tags: [Graph API]
      parameters:
        - name: entity_id
          in: query
          required: true
          schema:
            type: string
          description: Stream gauge or sensor ID.
          example: "gauge_colorado_a7"
        - name: max_distance_km
          in: query
          required: false
          schema:
            type: number
            default: 50
          description: Maximum upstream search distance in kilometres. Default 50 km.
      responses:
        "200":
          description: Upstream sensors returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      entity_id:
                        type: string
                      upstream_sensors:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            distance_km:
                              type: number
                            travel_time_hr:
                              type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  entity_id: "gauge_colorado_a7"
                  upstream_sensors:
                    - id: "gauge_colorado_a3"
                      distance_km: 8.2
                      travel_time_hr: 1.4
                    - id: "gauge_colorado_a1"
                      distance_km: 17.6
                      travel_time_hr: 3.1
                    - id: "rain_gauge_07"
                      distance_km: 22.4
                      travel_time_hr: 4.2
                meta:
                  confidence: 0.95
                  model_version: "graph-upstream-v1.2.0"
                  calibration_age_days: 30
                  site_calibrated: true
                  provenance:
                    - "watershed:USGS-WBD-2025"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 58
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Entity not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Calibration ──────────────────────────────────────────────────────────────

  /model/calibration:
    get:
      operationId: getModelCalibration
      summary: Site-specific model calibration state
      description: |
        Returns the current calibration state for a given propagation model and site —
        including calibration age, parameter corrections applied, and prediction accuracy
        metrics over the last 30/90 days.

        Requires Site Calibration plan.

        **Pricing:** $0.001 / call (Site Calibration plan only)
      tags: [Calibration]
      parameters:
        - name: model
          in: query
          required: true
          schema:
            type: string
            enum: [plume, flood, rf, noise, thermal]
          description: Propagation model to query calibration state for.
          example: "plume"
        - name: site_id
          in: query
          required: true
          schema:
            type: string
          description: Site identifier.
          example: "wellpad_07"
      responses:
        "200":
          description: Calibration state returned.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: object
                    properties:
                      model:
                        type: string
                        example: "plume"
                      site_id:
                        type: string
                        example: "wellpad_07"
                      calibration_age_days:
                        type: integer
                        example: 47
                      last_calibrated_at:
                        type: string
                        format: date-time
                      accuracy_30d:
                        type: object
                        properties:
                          mean_absolute_error:
                            type: number
                          r_squared:
                            type: number
                      corrections_applied:
                        type: object
                        description: Site-specific model parameter overrides.
                        properties:
                          stability_correction:
                            type: number
                          terrain_correction_factor:
                            type: number
                          effective_emission_height_m:
                            type: number
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  model: "plume"
                  site_id: "wellpad_07"
                  calibration_age_days: 47
                  last_calibrated_at: "2026-03-05T14:22:00Z"
                  accuracy_30d:
                    mean_absolute_error: 0.18
                    r_squared: 0.94
                  corrections_applied:
                    stability_correction: 0.05
                    terrain_correction_factor: 1.12
                    effective_emission_height_m: 14.2
                meta:
                  confidence: 0.97
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "calibration:wellpad_07-2026-03-05"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 42
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: Site Calibration plan required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: No calibration data for this model/site combination.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Jobs ─────────────────────────────────────────────────────────────────────

  /jobs/{job_id}:
    get:
      operationId: getJob
      summary: Poll async job status and retrieve result
      description: |
        Returns the current status of an async simulation job. When status is `complete`,
        the full result is included in the `result` field of the response envelope.
        Jobs expire 48 hours after completion.
      tags: [Jobs]
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
          description: Job ID returned from a POST /simulate/* or other async endpoint.
          example: "job_01hx2a3f9k8qmzn7p"
      responses:
        "200":
          description: Job status (and result if complete).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  job:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                      status:
                        type: string
                        enum: [queued, running, complete, failed]
                      eta_seconds:
                        type: integer
                        nullable: true
                      webhook_delivered:
                        type: boolean
                  result:
                    description: Present only when status is `complete`.
                    nullable: true
                    type: object
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                job:
                  id: "job_01hx2a3f9k8qmzn7p"
                  type: "simulate/plume"
                  status: "complete"
                  eta_seconds: null
                  webhook_delivered: true
                result:
                  isopleths:
                    - concentration_ppm: 10
                      polygon:
                        type: "Polygon"
                        coordinates:
                          - - - -99.9050
                              - 31.9645
                            - - -99.9020
                              - 31.9648
                            - - -99.9018
                              - 31.9670
                            - - -99.9048
                              - 31.9667
                            - - -99.9050
                              - 31.9645
                  max_concentration_ppm: 58.1
                  receptors:
                    - device_id: "sensor_h2s_023"
                      concentration_ppm: 4.7
                meta:
                  confidence: 0.92
                  model_version: "plume-v4.1.2"
                  calibration_age_days: 47
                  site_calibrated: true
                  provenance:
                    - "met:NOAA-HRRR-2026-04-21T03Z"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 17
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Job not found or expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── Webhooks ─────────────────────────────────────────────────────────────────

  /webhooks:
    get:
      operationId: listWebhooks
      summary: List all registered webhooks for the account
      description: |
        Returns every webhook registered on the calling account, ordered by `created_at` descending.
        The signing `secret` is **never** returned by this endpoint — it is only available on the
        initial `POST /webhooks` response. To rotate a secret, delete the webhook and register a new one.
      tags: [Webhooks]
      parameters:
        - in: query
          name: active
          required: false
          schema:
            type: boolean
          description: If supplied, filters the result to only active (`true`) or paused (`false`) webhooks.
      responses:
        "200":
          description: Array of webhook records (without `secret`).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: array
                    items:
                      $ref: "#/components/schemas/Webhook"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  - id: "whk_01hx2f8q7n3tdvm5j"
                    url: "https://api.acme.com/hooks/geog"
                    events:
                      - "job.complete"
                      - "job.failed"
                      - "webhook.disabled"
                    description: "Production plume results → SOC pipeline"
                    active: true
                    disabled_at: null
                    created_at: "2026-04-21T03:14:22Z"
                  - id: "whk_01hx2f9r1k4uxwn6m"
                    url: "https://api.acme.com/hooks/geog-staging"
                    events:
                      - "job.complete"
                    description: "Staging mirror"
                    active: false
                    disabled_at: "2026-04-21T03:48:11Z"
                    disabled_reason: "auto_failure_rate"
                    created_at: "2026-04-15T09:02:11Z"
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 22
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []
    post:
      operationId: registerWebhook
      summary: Register a webhook endpoint to receive async job results
      description: |
        Registers an HTTPS callback URL on your account. Once registered, every async job your
        account creates (`/simulate/*`, `/rf/mesh/viability`, `/rf/optimize/placement`,
        `/optimize/*`) will POST its full result envelope to this URL when it transitions to
        `complete` or `failed`. Per-job overrides can still be supplied via the `webhook_url`
        field on the job request body.

        ## Delivery payload

        Each delivery is a `POST` with `Content-Type: application/json` containing the standard
        `{ ok, event, result, meta }` envelope. The shape of `result` depends on `event` — see
        the per-event schemas at the top of the [Webhooks tag](#tag/Webhooks): `JobCompleteEvent`,
        `JobFailedEvent`, `CalibrationUpdatedEvent`, and `WebhookDisabledEvent` (the discriminated
        union is `WebhookEventDelivery`). For `job.complete` and `job.failed`, `result` is the
        same body returned by `GET /jobs/{job_id}` once the job has reached a terminal state.

        The following headers are always set on the delivery:

        | Header | Description |
        | --- | --- |
        | `X-Geog-Signature` | Hex-encoded HMAC-SHA256 of `{timestamp}.{raw_body}` using the webhook secret. Format: `t=<unix_ts>,v1=<hex_digest>` |
        | `X-Geog-Event` | Event type (`job.complete`, `job.failed`, `calibration.updated`, `webhook.disabled`). Always matches the `event` field in the body. |
        | `X-Geog-Delivery` | Unique delivery ID — safe to use for idempotency on the receiver side |
        | `X-Geog-Webhook-Id` | ID of the registered webhook that produced this delivery |
        | `X-Geog-Test` | Present and set to `true` only on deliveries produced by `POST /webhooks/{id}/test`. Absent on real job-driven deliveries — receivers can use it to short-circuit production side-effects for synthetic traffic |

        ## Verifying the signature (HMAC-SHA256)

        1. Read the `X-Geog-Signature` header and parse the comma-separated `t=` (unix timestamp,
           seconds) and `v1=` (hex digest) parts.
        2. Reject the delivery if `|now - t| > 300` seconds to mitigate replay attacks.
        3. Concatenate `t`, a literal `.`, and the raw request body bytes.
        4. Compute `HMAC-SHA256(secret, payload).hexdigest()` and compare it to `v1` using a
           constant-time comparison (e.g. `hmac.compare_digest`). Reject on mismatch.

        ## Retry behaviour

        Non-2xx responses (or no response within 10 seconds) trigger automatic retries with
        exponential backoff at 30s, 2m, 10m, 30m, 2h, 6h, and 24h. After 7 failed attempts the
        delivery is marked `undeliverable`. If 50% of deliveries in the last hour have failed
        the webhook is automatically disabled (`active=false`, `disabled_reason=auto_failure_rate`)
        and an email is sent to the account owner. To detect this auto-disable programmatically
        rather than relying on the owner's inbox, either subscribe to the `webhook.disabled`
        event (delivered on the final attempt before the webhook is paused — best subscribed on
        a separately-monitored receiver) or poll `GET /webhooks/{id}/health`, which returns the
        current rolling-hour failure rate, the auto-disable threshold, and the `disabled_at`
        timestamp once tripped. All deliveries (including failures) are visible on the dashboard
        for 30 days.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookRegistration"
            example:
              url: "https://api.acme.com/hooks/geog"
              events:
                - "job.complete"
                - "job.failed"
                - "webhook.disabled"
              description: "Production plume results → SOC pipeline"
              active: true
      responses:
        "200":
          description: Webhook registered successfully. The `secret` field is returned only on this initial response.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/Webhook"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  id: "whk_01hx2f8q7n3tdvm5j"
                  url: "https://api.acme.com/hooks/geog"
                  events:
                    - "job.complete"
                    - "job.failed"
                    - "webhook.disabled"
                  description: "Production plume results → SOC pipeline"
                  active: true
                  disabled_at: null
                  secret: "whsec_8k3Jq2pNvR7tYxL4mZ9cFwH1bD5sQ6aU"
                  created_at: "2026-04-21T03:14:22Z"
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 27
        "400":
          description: |
            Invalid request body. Possible error codes:
            - `INVALID_WEBHOOK_URL` — URL is missing, malformed, not HTTPS, or resolves to a private/loopback address
            - `UNSUPPORTED_EVENT` — one or more entries in `events` is not a recognised event type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: Account already has the maximum number of registered webhooks (`WEBHOOK_LIMIT_EXCEEDED`, max 10).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{webhook_id}:
    parameters:
      - in: path
        name: webhook_id
        required: true
        schema:
          type: string
        description: ID of the registered webhook (prefix `whk_`).
        example: "whk_01hx2f8q7n3tdvm5j"
    get:
      operationId: getWebhook
      summary: Retrieve a single registered webhook
      description: |
        Returns the current configuration of one webhook. The signing `secret` is **not**
        returned — it is only available on the original `POST /webhooks` response. Rotate by
        deleting and re-registering.
      tags: [Webhooks]
      responses:
        "200":
          description: Webhook record (without `secret`).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/Webhook"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  id: "whk_01hx2f8q7n3tdvm5j"
                  url: "https://api.acme.com/hooks/geog"
                  events:
                    - "job.complete"
                    - "job.failed"
                    - "webhook.disabled"
                  description: "Production plume results → SOC pipeline"
                  active: true
                  disabled_at: null
                  created_at: "2026-04-21T03:14:22Z"
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 19
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: No webhook with this ID exists on the calling account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []
    patch:
      operationId: updateWebhook
      summary: Update a registered webhook
      description: |
        Partially updates a webhook. Send only the fields you want to change. Common uses:
        toggling `active` to pause/resume deliveries, re-pointing `url` after a backend
        migration, or narrowing the subscribed `events`.

        The signing `secret` cannot be changed via this endpoint. To rotate it, `DELETE` the
        webhook and register a new one with `POST /webhooks` — store the new secret from that
        response, then cut over your verification code.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookUpdate"
            example:
              url: "https://api.acme.com/hooks/geog-v2"
              events:
                - "job.complete"
                - "job.failed"
                - "webhook.disabled"
              description: "Production plume results → SOC pipeline (v2)"
              active: false
      responses:
        "200":
          description: Updated webhook record (without `secret`).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/Webhook"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  id: "whk_01hx2f8q7n3tdvm5j"
                  url: "https://api.acme.com/hooks/geog-v2"
                  events:
                    - "job.complete"
                    - "job.failed"
                    - "webhook.disabled"
                  description: "Production plume results → SOC pipeline (v2)"
                  active: false
                  disabled_at: "2026-04-21T03:48:11Z"
                  disabled_reason: "manual"
                  created_at: "2026-04-21T03:14:22Z"
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 24
        "400":
          description: |
            Invalid request body. Possible error codes:
            - `INVALID_WEBHOOK_URL` — `url` is malformed, not HTTPS, or resolves to a private/loopback address
            - `UNSUPPORTED_EVENT` — one or more entries in `events` is not a recognised event type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: No webhook with this ID exists on the calling account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []
    delete:
      operationId: deleteWebhook
      summary: Delete a registered webhook
      description: |
        Permanently removes a webhook. Deliveries already in flight may still arrive briefly,
        but no new deliveries will be queued. Use this to revoke a leaked endpoint or to rotate
        the signing secret (delete, then `POST /webhooks` to register a fresh one).
      tags: [Webhooks]
      responses:
        "204":
          description: Webhook deleted. No response body.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: No webhook with this ID exists on the calling account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{id}/test:
    post:
      operationId: sendWebhookTestEvent
      summary: Send a synthetic event to a webhook to verify the receiver
      description: |
        Generates a synthetic delivery for the registered webhook and POSTs it to the URL
        exactly as a real event would be sent — same envelope shape, same `X-Geog-Signature`
        HMAC, same retry rules. Designed for day-one verification of a brand-new receiver
        before any real job has run: confirm that the URL is reachable, the TLS certificate
        is valid, and the receiver's signature-verification code accepts the payload.

        ## Distinguishing test deliveries

        Test deliveries carry an additional `X-Geog-Test: true` header on the outbound POST,
        and the resulting `WebhookDelivery` record reports `test: true`. The synthetic
        `job_id` is prefixed `job_test_` so it cannot collide with a real job. Either signal
        is safe to gate on; the header is recommended so the receiver can short-circuit
        production side-effects (alerting, billing, downstream writes) before parsing the
        body.

        Test deliveries appear in `GET /webhooks/{id}/deliveries` alongside real ones, are
        subject to the same retry schedule and auto-disable rules, and are throttled to 60
        test deliveries per webhook per hour (`TEST_RATE_LIMITED`).
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Webhook identifier returned from `POST /webhooks` (prefix `whk_`).
          example: "whk_01hx2f8q7n3tdvm5j"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
                  default: job.complete
                  description: |
                    Event type to simulate. Must be one of the event types the webhook is
                    subscribed to or the request returns `400 UNSUPPORTED_EVENT`. Selecting
                    `webhook.disabled` only generates a synthetic delivery — it does **not**
                    actually disable the webhook.
                  example: "job.complete"
            example:
              event: "job.complete"
      responses:
        "202":
          description: "Test delivery accepted and queued. The returned `WebhookDelivery` reflects the new `pending` state with `test: true`."
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/WebhookDelivery"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  id: "whd_01hx2g4n8p7rqzm3k"
                  webhook_id: "whk_01hx2f8q7n3tdvm5j"
                  event: "job.complete"
                  job_id: "job_test_01hx2a3f9k8qmzn7p"
                  status: "pending"
                  attempt_count: 1
                  response_code: null
                  response_ms: null
                  response_body_excerpt: null
                  error: null
                  signature_timestamp: 1745211262
                  created_at: "2026-04-21T03:14:22Z"
                  last_attempt_at: null
                  delivered_at: null
                  next_attempt_at: "2026-04-21T03:14:22Z"
                  test: true
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 33
        "400":
          description: |
            Invalid request body. Possible error codes:
            - `UNSUPPORTED_EVENT` — requested `event` is not a recognised event type or is not in the webhook's subscribed `events` list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Webhook not found or owned by another account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: |
            Webhook is currently inactive (`WEBHOOK_DISABLED` — `active=false` or auto-disabled
            after sustained failures); re-enable it on the dashboard before sending a test.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: |
            Possible error codes:
            - `TEST_RATE_LIMITED` — more than 60 test deliveries have been requested for this webhook in the last hour
            - `RATE_LIMITED` — account-level rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      summary: List recent deliveries for a webhook
      description: |
        Returns a paginated list of delivery attempts for the given webhook, newest first.
        Mirrors the delivery log shown on the dashboard and is intended for debugging
        receiver-side bugs without waiting for the next real job.

        Deliveries are retained for 30 days; older records return an empty page rather than
        an error. Use the `status` filter to narrow to actively-failing deliveries you may
        want to replay after fixing your handler.
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Webhook identifier returned from `POST /webhooks` (prefix `whk_`).
          example: "whk_01hx2f8q7n3tdvm5j"
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, success, failed, undeliverable]
          description: Filter results to a single delivery status. Default returns all statuses.
          example: "failed"
        - name: event
          in: query
          required: false
          schema:
            type: string
            enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          description: Filter results to a single event type.
        - name: test
          in: query
          required: false
          schema:
            type: boolean
          description: |
            Filter by delivery origin. `true` returns only synthetic deliveries produced
            by `POST /webhooks/{id}/test`; `false` returns only real job-driven
            deliveries. Omit (the default) to return both kinds.

            Composes with `status` and `event` using AND semantics — for example,
            `?test=true&status=failed` returns only failed test deliveries, useful when
            verifying a brand-new receiver without scrolling past real job results.
          example: true
        - name: created_after
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Inclusive lower bound on the delivery's `created_at` timestamp (ISO 8601, UTC).
            Only deliveries first queued at or after this instant are returned.

            Composes with `status`, `event`, and `test` using AND semantics — for example,
            `?status=failed&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z`
            returns only failed deliveries queued during that one-hour window, useful for
            scoping a debug session to a known outage. Bounded by the 30-day retention window.
          example: "2026-04-21T02:00:00Z"
        - name: created_before
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Exclusive upper bound on the delivery's `created_at` timestamp (ISO 8601, UTC).
            Only deliveries first queued strictly before this instant are returned.

            May be combined with `created_after` to define a closed-open `[after, before)`
            range, or used on its own to scope to "everything older than X". Composes with
            `status`, `event`, and `test` using AND semantics, identical to the other filters.
          example: "2026-04-21T03:00:00Z"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Page size (max 100).
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned as `meta.next_cursor` on the previous page. Omit for the first page.
      responses:
        "200":
          description: Page of webhook deliveries, newest first.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDelivery"
                  meta:
                    type: object
                    properties:
                      request_id:
                        type: string
                        example: "req_01hx2g5p3q8wnzvr4"
                      next_cursor:
                        type: string
                        nullable: true
                        description: Opaque cursor for the next page. `null` when there are no more results.
                        example: "eyJ0IjoxNzQ1MjEwMDAwLCJpZCI6IndoZF8wMWh4MmcxIn0"
              example:
                ok: true
                result:
                  - id: "whd_01hx2g4n8p7rqzm3k"
                    webhook_id: "whk_01hx2f8q7n3tdvm5j"
                    event: "job.complete"
                    job_id: "job_01hx2a3f9k8qmzn7p"
                    status: "failed"
                    attempt_count: 3
                    response_code: 502
                    response_ms: 482
                    response_body_excerpt: "<html><head><title>502 Bad Gateway</title></head>..."
                    error: null
                    signature_timestamp: 1745211262
                    created_at: "2026-04-21T03:14:22Z"
                    last_attempt_at: "2026-04-21T03:24:51Z"
                    delivered_at: null
                    next_attempt_at: "2026-04-21T03:34:51Z"
                    test: false
                  - id: "whd_01hx2g4n8p7rqzm3l"
                    webhook_id: "whk_01hx2f8q7n3tdvm5j"
                    event: "job.complete"
                    job_id: "job_01hx2a3f9k8qmzn7q"
                    status: "success"
                    attempt_count: 1
                    response_code: 200
                    response_ms: 144
                    response_body_excerpt: '{"received":true}'
                    error: null
                    signature_timestamp: 1745210500
                    created_at: "2026-04-21T03:01:40Z"
                    last_attempt_at: "2026-04-21T03:01:40Z"
                    delivered_at: "2026-04-21T03:01:40Z"
                    next_attempt_at: null
                    test: false
                meta:
                  request_id: "req_01hx2g5p3q8wnzvr4"
                  next_cursor: "eyJ0IjoxNzQ1MjEwMDAwLCJpZCI6IndoZF8wMWh4MmcxIn0"
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Webhook not found or owned by another account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{id}/deliveries.csv:
    get:
      operationId: exportWebhookDeliveriesCsv
      summary: Export filtered deliveries as CSV
      description: |
        Returns the same filtered, paginated rows as `GET /webhooks/{id}/deliveries`,
        serialised as a CSV stream rather than a JSON envelope. Intended for one-off
        ops investigations: pipe the response into a file, hand it to a teammate, or
        paste it into a spreadsheet without scripting against the JSON endpoint.

        The response uses `Content-Type: text/csv; charset=utf-8` and is sent with
        `Content-Disposition: attachment; filename="deliveries-<webhook_id>.csv"`
        so browsers and `curl -OJ` save it directly to disk.

        ## CSV format

        - First row is a header. Columns are emitted in this fixed order, matching
          the JSON `WebhookDelivery` fields:

          `id,webhook_id,event,job_id,status,attempt_count,response_code,response_ms,response_body_excerpt,error,signature_timestamp,created_at,last_attempt_at,delivered_at,next_attempt_at,test`

        - Rows are newest-first, identical to the JSON ordering.
        - Values follow [RFC 4180](https://datatracker.ietf.org/doc/html/rfc4180):
          fields containing commas, quotes, or newlines are wrapped in double quotes,
          and embedded double quotes are doubled (`"` → `""`). The
          `response_body_excerpt` column in particular often needs quoting.
        - JSON `null` values (e.g. `job_id` for non-job events, `delivered_at` while
          still pending) are emitted as **empty fields**, not the literal string
          `null`. The `test` boolean is emitted as `true` / `false`.
        - Timestamps are ISO 8601 UTC, identical to the JSON representation.
        - Line terminator is `\r\n`.

        ## Filters

        Honours all the same query parameters as the JSON endpoint with identical
        AND semantics: `status`, `event`, `test`, `created_after`, `created_before`,
        `limit`, and `cursor`. The 30-day retention window applies — older rows are
        simply absent from the CSV rather than producing an error.

        ## Pagination

        CSV bodies cannot carry a JSON `meta` object, so the next-page cursor is
        returned in the **`X-Next-Cursor` response header**. When there are no more
        pages the header is omitted. To export every matching delivery, follow the
        cursor:

        ```bash
        cursor=""
        while :; do
          resp=$(curl -sS -D - -H "Authorization: Bearer $GEOG_KEY" \
            "https://api.geog.ai/v1/webhooks/$ID/deliveries.csv?status=failed${cursor:+&cursor=$cursor}")
          # append body (stripping the header row on subsequent pages) to the output file...
          cursor=$(printf '%s' "$resp" | awk 'BEGIN{IGNORECASE=1} /^x-next-cursor:/ {print $2}' | tr -d '\r')
          [ -z "$cursor" ] && break
        done
        ```

        The same `meta.request_id` value that the JSON endpoint returns is exposed
        as the `X-Request-Id` response header for support tickets.
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Webhook identifier returned from `POST /webhooks` (prefix `whk_`).
          example: "whk_01hx2f8q7n3tdvm5j"
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, success, failed, undeliverable]
          description: Filter results to a single delivery status. Default returns all statuses. Identical semantics to the JSON endpoint.
          example: "failed"
        - name: event
          in: query
          required: false
          schema:
            type: string
            enum: [job.complete, job.failed, calibration.updated, webhook.disabled]
          description: Filter results to a single event type. Identical semantics to the JSON endpoint.
        - name: test
          in: query
          required: false
          schema:
            type: boolean
          description: |
            Filter by delivery origin. `true` returns only synthetic deliveries
            produced by `POST /webhooks/{id}/test`; `false` returns only real
            job-driven deliveries. Omit (the default) to return both kinds.
            Composes with `status` and `event` using AND semantics, identical to
            the JSON endpoint.
          example: true
        - name: created_after
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Inclusive lower bound on the delivery's `created_at` timestamp
            (ISO 8601, UTC). Identical semantics to the JSON endpoint —
            for example, `?status=failed&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z`
            exports only failed deliveries queued during that one-hour window,
            useful for handing a teammate the exact slice that lines up with an
            outage.
          example: "2026-04-21T02:00:00Z"
        - name: created_before
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Exclusive upper bound on the delivery's `created_at` timestamp
            (ISO 8601, UTC). May be combined with `created_after` to define a
            closed-open `[after, before)` range. Identical semantics to the JSON
            endpoint.
          example: "2026-04-21T03:00:00Z"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Page size (max 100). Identical default and bounds to the JSON endpoint.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: |
            Opaque pagination cursor returned in the `X-Next-Cursor` response
            header on the previous page. Omit for the first page. The cursor
            format is interchangeable with the JSON endpoint's `meta.next_cursor`.
      responses:
        "200":
          description: |
            CSV stream of webhook deliveries matching the filters, newest first.
            Always includes a header row. When more pages remain, an
            `X-Next-Cursor` response header carries the opaque cursor for the
            next page; it is omitted on the final page.
          headers:
            Content-Disposition:
              schema:
                type: string
              description: Always `attachment; filename="deliveries-<webhook_id>.csv"` so browsers and `curl -OJ` save the response to disk.
              example: 'attachment; filename="deliveries-whk_01hx2f8q7n3tdvm5j.csv"'
            X-Next-Cursor:
              schema:
                type: string
              description: Opaque cursor for the next page. Omitted (header absent) when there are no more results.
              example: "eyJ0IjoxNzQ1MjEwMDAwLCJpZCI6IndoZF8wMWh4MmcxIn0"
            X-Request-Id:
              schema:
                type: string
              description: Unique request identifier for support tickets. Equivalent to `meta.request_id` on the JSON endpoint.
              example: "req_01hx2g5p3q8wnzvr4"
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            text/csv:
              schema:
                type: string
                description: |
                  RFC 4180 CSV with a fixed header row. Columns, in order:
                  `id,webhook_id,event,job_id,status,attempt_count,response_code,response_ms,response_body_excerpt,error,signature_timestamp,created_at,last_attempt_at,delivered_at,next_attempt_at,test`.
                  JSON `null` values are emitted as empty fields.
              example: |
                id,webhook_id,event,job_id,status,attempt_count,response_code,response_ms,response_body_excerpt,error,signature_timestamp,created_at,last_attempt_at,delivered_at,next_attempt_at,test
                whd_01hx2g4n8p7rqzm3k,whk_01hx2f8q7n3tdvm5j,job.complete,job_01hx2a3f9k8qmzn7p,failed,3,502,482,"<html><head><title>502 Bad Gateway</title></head>...",,1745211262,2026-04-21T03:14:22Z,2026-04-21T03:24:51Z,,2026-04-21T03:34:51Z,false
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`). The error envelope is returned as JSON, not CSV.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Webhook not found or owned by another account (`WEBHOOK_NOT_FOUND`). The error envelope is returned as JSON, not CSV.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded. The error envelope is returned as JSON, not CSV.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{id}/deliveries/{delivery_id}/replay:
    post:
      operationId: replayWebhookDelivery
      summary: Re-send a previously attempted webhook delivery
      description: |
        Re-queues a previously attempted delivery for immediate re-send to the webhook's
        currently registered URL. Useful after fixing a receiver-side bug — there is no need
        to wait for the next real job.

        **Replays use the original payload and signature timestamp.** The exact bytes that
        were originally signed are re-sent, and the original `X-Geog-Signature` (including
        its `t=` value) and `X-Geog-Delivery` ID are reused. This means:

        - HMAC verification on the receiver behaves identically to the original delivery.
        - The 5-minute replay-window check (`|now - t| > 300`) **will reject the request**
          if the original signature timestamp is older than 5 minutes. Receivers that need
          to accept replays of old deliveries should relax this check for requests carrying
          a known `X-Geog-Delivery` ID.
        - Idempotent receivers keyed on `X-Geog-Delivery` will naturally deduplicate.

        The replay attempt counts toward the webhook's retry budget and the delivery's
        `attempt_count` is incremented. Calling replay on a delivery whose `status` is
        already `pending` returns `409 DELIVERY_ALREADY_PENDING`.
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Webhook identifier (prefix `whk_`).
          example: "whk_01hx2f8q7n3tdvm5j"
        - name: delivery_id
          in: path
          required: true
          schema:
            type: string
          description: Delivery identifier returned by `GET /webhooks/{id}/deliveries` (prefix `whd_`).
          example: "whd_01hx2g4n8p7rqzm3k"
      responses:
        "202":
          description: Replay accepted and queued. The returned `WebhookDelivery` reflects the new `pending` state.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/WebhookDelivery"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  id: "whd_01hx2g4n8p7rqzm3k"
                  webhook_id: "whk_01hx2f8q7n3tdvm5j"
                  event: "job.complete"
                  job_id: "job_01hx2a3f9k8qmzn7p"
                  status: "pending"
                  attempt_count: 4
                  response_code: null
                  response_ms: null
                  response_body_excerpt: null
                  error: null
                  signature_timestamp: 1745211262
                  created_at: "2026-04-21T03:14:22Z"
                  last_attempt_at: "2026-04-21T03:24:51Z"
                  delivered_at: null
                  next_attempt_at: "2026-04-21T03:48:31Z"
                  test: false
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 31
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: |
            Possible error codes:
            - `WEBHOOK_NOT_FOUND` — webhook does not exist or is owned by another account
            - `DELIVERY_NOT_FOUND` — delivery does not belong to this webhook or has aged past the 30-day retention window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: |
            Possible error codes:
            - `DELIVERY_ALREADY_PENDING` — the delivery is already queued or in-flight; wait for it to settle before replaying
            - `WEBHOOK_DISABLED` — the webhook is currently inactive (`active=false` or auto-disabled after sustained failures); re-enable it on the dashboard before replaying
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  /webhooks/{id}/health:
    get:
      operationId: getWebhookHealth
      summary: Read the rolling-hour delivery health for a webhook
      description: |
        Returns the same rolling-window failure-rate counters that drive the platform's
        auto-disable rule, plus the current `active` / `disabled_reason` state. Use this
        endpoint to detect — programmatically, without depending on the account owner's
        inbox — when a webhook has been auto-disabled after sustained delivery failures
        (≥50% failed deliveries in the last hour), or is trending toward it.

        Typical patterns:

        - **Polling guard.** Call once a minute from the same job that registered the
          webhook. Alert your own on-call channel as soon as `status == "degraded"`
          (failure rate has crossed 25%) so you can fix the receiver before the platform
          auto-disables it. Treat `status == "disabled"` with `disabled_reason ==
          "auto_failure_rate"` as a hard incident.
        - **Post-mortem.** After receiving a `webhook.disabled` event delivery (or the
          owner-email alert), call this endpoint to read `failure_rate`, `failed_count`,
          `undeliverable_count`, and `last_success_at` to understand exactly what tripped
          the auto-disable rule.

        This endpoint is read-only and cheap — it does not consume webhook delivery quota
        and is rate-limited only by the standard account-level rate limit.
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Webhook identifier returned from `POST /webhooks` (prefix `whk_`).
          example: "whk_01hx2f8q7n3tdvm5j"
      responses:
        "200":
          description: Current health snapshot.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  result:
                    $ref: "#/components/schemas/WebhookHealth"
                  meta:
                    $ref: "#/components/schemas/ResponseMeta"
              example:
                ok: true
                result:
                  webhook_id: "whk_01hx2f8q7n3tdvm5j"
                  active: false
                  status: "disabled"
                  disabled_at: "2026-04-21T03:48:11Z"
                  disabled_reason: "auto_failure_rate"
                  window_seconds: 3600
                  as_of: "2026-04-21T03:48:11Z"
                  delivery_count: 42
                  success_count: 18
                  failed_count: 3
                  undeliverable_count: 21
                  pending_count: 0
                  failure_rate: 0.571
                  auto_disable_threshold: 0.5
                  consecutive_failures: 7
                  last_success_at: "2026-04-21T03:11:08Z"
                  last_failure_at: "2026-04-21T03:47:54Z"
                meta:
                  confidence: 1.0
                  model_version: "webhooks-v1.0.0"
                  calibration_age_days: 0
                  site_calibrated: true
                  provenance:
                    - "registry:tenant-2026-04-21"
                  request_id: "req_01hx29sknd4bqyt8r"
                  latency_ms: 18
        "401":
          description: Invalid or missing API key (`INVALID_API_KEY`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Webhook not found or owned by another account (`WEBHOOK_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: Rate limit exceeded (`RATE_LIMITED`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
      security:
        - BearerAuth: []

  # ─── STAC Catalog API (geog-ai-14) ───────────────────────────────────────────

  /v1/stac/:
    get:
      operationId: stacRoot
      summary: STAC catalog root
      description: |
        STAC-API §1.0.0 catalog root. Carries the conformance set and
        navigation links. Tenant-scoped: collections listed are filtered
        to the caller's `vertical_ids` and own `tenant_id`.

        **Conformance set:** Core, Collections, OGC API Features, Item Search
        (GET + POST). CQL2 filter is intentionally **not** advertised — see
        the geog.ai roadmap entry `geog-ai-15` for the planned addition.

        **Pricing:** $0.002 / call (read family, 2.0 cost units).
      tags: [STAC Catalog]
      responses:
        "200":
          description: STAC Catalog object.
          content:
            application/json:
              schema:
                type: object
                required: [type, stac_version, id, conformsTo, links]
                properties:
                  type:         { type: string, enum: [Catalog] }
                  stac_version: { type: string, example: "1.0.0" }
                  id:           { type: string, example: "geog-ai-catalog" }
                  title:        { type: string }
                  description:  { type: string }
                  conformsTo:
                    type: array
                    items: { type: string, format: uri }
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/StacLink" }
              example:
                type: "Catalog"
                stac_version: "1.0.0"
                id: "geog-ai-catalog"
                title: "geog.ai STAC Catalog"
                description: "Tenant-scoped STAC catalog of completed simulation jobs."
                conformsTo:
                  - "https://api.stacspec.org/v1.0.0/core"
                  - "https://api.stacspec.org/v1.0.0/collections"
                  - "https://api.stacspec.org/v1.0.0/ogcapi-features"
                  - "https://api.stacspec.org/v1.0.0/item-search"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
                  - rel: "root"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
                  - rel: "conformance"
                    href: "https://api.geog.ai/v1/stac/conformance"
                    type: "application/json"
                  - rel: "data"
                    href: "https://api.geog.ai/v1/stac/collections"
                    type: "application/json"
                  - rel: "search"
                    href: "https://api.geog.ai/v1/stac/search"
                    type: "application/geo+json"
                    method: "GET"
                  - rel: "search"
                    href: "https://api.geog.ai/v1/stac/search"
                    type: "application/geo+json"
                    method: "POST"
      security:
        - BearerAuth: []

  /v1/stac/conformance:
    get:
      operationId: stacConformance
      summary: STAC conformance classes implemented by this server
      tags: [STAC Catalog]
      responses:
        "200":
          description: STAC §3 Conformance object.
          content:
            application/json:
              schema:
                type: object
                required: [conformsTo]
                properties:
                  conformsTo:
                    type: array
                    items: { type: string, format: uri }
              example:
                conformsTo:
                  - "https://api.stacspec.org/v1.0.0/core"
                  - "https://api.stacspec.org/v1.0.0/collections"
                  - "https://api.stacspec.org/v1.0.0/ogcapi-features"
                  - "https://api.stacspec.org/v1.0.0/item-search"
      security:
        - BearerAuth: []

  /v1/stac/collections:
    get:
      operationId: stacCollections
      summary: List STAC Collections visible to the caller
      description: |
        One Collection per `(engine, vertical_id)` tuple the tenant has
        produced jobs for. Each Collection carries `extent.spatial.bbox`,
        `extent.temporal.interval`, a per-tenant `license` (varies by plan),
        and a `providers` chain naming geog.ai as licensor and the engine
        as processor.
      tags: [STAC Catalog]
      responses:
        "200":
          description: Collection list.
          content:
            application/json:
              schema:
                type: object
                required: [collections, links]
                properties:
                  collections:
                    type: array
                    items: { $ref: "#/components/schemas/StacCollection" }
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/StacLink" }
              example:
                collections:
                  - type: "Collection"
                    stac_version: "1.0.0"
                    id: "plume_aermod__oilgas"
                    title: "AERMOD plume runs (oil & gas vertical)"
                    description: "Completed AERMOD plume jobs for the oil & gas vertical."
                    license: "proprietary-pro"
                    providers:
                      - name: "geog.ai"
                        roles:
                          - "licensor"
                          - "host"
                        url: "https://geog.ai"
                      - name: "aermod"
                        roles:
                          - "processor"
                    extent:
                      spatial:
                        bbox:
                          - - -104.5
                            - 30.5
                            - -94.0
                            - 36.5
                      temporal:
                        interval:
                          - - "2026-01-01T00:00:00Z"
                            - null
                    links:
                      - rel: "self"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                      - rel: "items"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items"
                        type: "application/geo+json"
                      - rel: "parent"
                        href: "https://api.geog.ai/v1/stac/"
                        type: "application/json"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/collections"
                    type: "application/json"
                  - rel: "root"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
      security:
        - BearerAuth: []

  /v1/stac/collections/{collection_id}:
    get:
      operationId: stacCollection
      summary: Fetch a single STAC Collection
      tags: [STAC Catalog]
      parameters:
        - name: collection_id
          in: path
          required: true
          description: |
            Composite collection id — `{engine}__{vertical_id}` (e.g.
            `plume_aermod__oilgas`). Use `_unscoped` as the vertical
            sentinel for legacy rows that carry no vertical_id.
          schema: { type: string }
      responses:
        "200":
          description: STAC Collection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StacCollection" }
              example:
                type: "Collection"
                stac_version: "1.0.0"
                id: "plume_aermod__oilgas"
                title: "AERMOD plume runs (oil & gas vertical)"
                description: "Completed AERMOD plume jobs for the oil & gas vertical."
                license: "proprietary-pro"
                providers:
                  - name: "geog.ai"
                    roles:
                      - "licensor"
                      - "host"
                    url: "https://geog.ai"
                  - name: "aermod"
                    roles:
                      - "processor"
                extent:
                  spatial:
                    bbox:
                      - - -104.5
                        - 30.5
                        - -94.0
                        - 36.5
                  temporal:
                    interval:
                      - - "2026-01-01T00:00:00Z"
                        - null
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                    type: "application/json"
                  - rel: "items"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items"
                    type: "application/geo+json"
                  - rel: "parent"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
        "404":
          description: Collection not visible to this tenant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      security:
        - BearerAuth: []

  /v1/stac/collections/{collection_id}/items:
    get:
      operationId: stacCollectionItems
      summary: List STAC Items within a Collection
      tags: [STAC Catalog]
      parameters:
        - name: collection_id
          in: path
          required: true
          schema: { type: string }
        - name: bbox
          in: query
          required: false
          schema: { type: string }
          description: "WGS84 bbox `min_lng,min_lat,max_lng,max_lat`. Pushed into PostGIS via ST_Intersects."
        - name: datetime
          in: query
          required: false
          schema: { type: string }
          description: "ISO 8601 instant (`2024-01-01T00:00:00Z`) or interval (`start/end`, `..` for open ends)."
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: "Opaque HMAC-signed pagination cursor returned by the previous page's `next` link."
      responses:
        "200":
          description: STAC Item FeatureCollection (paged).
          content:
            application/geo+json:
              schema: { $ref: "#/components/schemas/StacItemCollection" }
              example:
                type: "FeatureCollection"
                features:
                  - type: "Feature"
                    stac_version: "1.0.0"
                    id: "job_01hx2a3f9k8qmzn7p"
                    collection: "plume_aermod__oilgas"
                    geometry:
                      type: "Polygon"
                      coordinates:
                        - - - -99.9050
                            - 31.9645
                          - - -99.9020
                            - 31.9648
                          - - -99.9018
                            - 31.9670
                          - - -99.9048
                            - 31.9667
                          - - -99.9050
                            - 31.9645
                    bbox:
                      - -99.9050
                      - 31.9645
                      - -99.9018
                      - 31.9670
                    properties:
                      datetime: "2026-04-21T03:14:22Z"
                      "geog:engine": "aermod"
                      "geog:vertical_id": "oilgas"
                      "geog:status": "complete"
                    assets:
                      isopleths:
                        href: "https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p/assets/isopleths.geojson"
                        type: "application/geo+json"
                        title: "Concentration isopleths"
                        roles:
                          - "data"
                    links:
                      - rel: "self"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items/job_01hx2a3f9k8qmzn7p"
                        type: "application/geo+json"
                      - rel: "parent"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                      - rel: "collection"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items"
                    type: "application/geo+json"
                  - rel: "root"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
                numberMatched: 1
                numberReturned: 1
      security:
        - BearerAuth: []

  /v1/stac/collections/{collection_id}/items/{item_id}:
    get:
      operationId: stacItem
      summary: Fetch a single STAC Item
      tags: [STAC Catalog]
      parameters:
        - { name: collection_id, in: path, required: true, schema: { type: string } }
        - { name: item_id,       in: path, required: true, schema: { type: string }, description: "geo_simulation_jobs.job_id" }
      responses:
        "200":
          description: STAC Item.
          content:
            application/geo+json:
              schema: { $ref: "#/components/schemas/StacItem" }
              example:
                type: "Feature"
                stac_version: "1.0.0"
                id: "job_01hx2a3f9k8qmzn7p"
                collection: "plume_aermod__oilgas"
                geometry:
                  type: "Polygon"
                  coordinates:
                    - - - -99.9050
                        - 31.9645
                      - - -99.9020
                        - 31.9648
                      - - -99.9018
                        - 31.9670
                      - - -99.9048
                        - 31.9667
                      - - -99.9050
                        - 31.9645
                bbox:
                  - -99.9050
                  - 31.9645
                  - -99.9018
                  - 31.9670
                properties:
                  datetime: "2026-04-21T03:14:22Z"
                  "geog:engine": "aermod"
                  "geog:vertical_id": "oilgas"
                  "geog:status": "complete"
                assets:
                  isopleths:
                    href: "https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p/assets/isopleths.geojson"
                    type: "application/geo+json"
                    title: "Concentration isopleths"
                    roles:
                      - "data"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items/job_01hx2a3f9k8qmzn7p"
                    type: "application/geo+json"
                  - rel: "parent"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                    type: "application/json"
                  - rel: "collection"
                    href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                    type: "application/json"
        "404":
          description: Item not visible to this tenant or not in this collection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      security:
        - BearerAuth: []

  /v1/stac/search:
    get:
      operationId: stacSearchGet
      summary: STAC Item Search (GET)
      description: |
        STAC §15 Item Search. Supported predicates:
          - `bbox` — pushed into PostGIS via `ST_Intersects`.
          - `datetime` — ISO instant or `start/end` interval.
          - `collections` — comma-separated `{engine}__{vertical_id}` list.
          - `limit` / `cursor` — opaque signed pagination.

        Tenant-scoped: every result row is intersected against the caller's
        `vertical_ids` and `tenant_id` (per Phase 3 §3.11 acceptance
        criterion 9). CQL2 filtering is **not** supported on this
        endpoint — see roadmap entry `geog-ai-15`.
      tags: [STAC Catalog]
      parameters:
        - { name: bbox,        in: query, required: false, schema: { type: string } }
        - { name: datetime,    in: query, required: false, schema: { type: string } }
        - { name: collections, in: query, required: false, schema: { type: string } }
        - { name: limit,       in: query, required: false, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: cursor,      in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: STAC Item FeatureCollection (paged).
          content:
            application/geo+json:
              schema: { $ref: "#/components/schemas/StacItemCollection" }
              example:
                type: "FeatureCollection"
                features:
                  - type: "Feature"
                    stac_version: "1.0.0"
                    id: "job_01hx2a3f9k8qmzn7p"
                    collection: "plume_aermod__oilgas"
                    geometry:
                      type: "Polygon"
                      coordinates:
                        - - - -99.9050
                            - 31.9645
                          - - -99.9020
                            - 31.9648
                          - - -99.9018
                            - 31.9670
                          - - -99.9048
                            - 31.9667
                          - - -99.9050
                            - 31.9645
                    bbox:
                      - -99.9050
                      - 31.9645
                      - -99.9018
                      - 31.9670
                    properties:
                      datetime: "2026-04-21T03:14:22Z"
                      "geog:engine": "aermod"
                      "geog:vertical_id": "oilgas"
                      "geog:status": "complete"
                    assets: {}
                    links:
                      - rel: "self"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items/job_01hx2a3f9k8qmzn7p"
                        type: "application/geo+json"
                      - rel: "parent"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                      - rel: "collection"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/search"
                    type: "application/geo+json"
                  - rel: "root"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
                numberMatched: 1
                numberReturned: 1
      security:
        - BearerAuth: []
    post:
      operationId: stacSearchPost
      summary: STAC Item Search (POST)
      description: |
        Identical predicate set to `GET /v1/stac/search`, sent as a JSON
        body. STAC clients use this surface when query strings would
        exceed proxy URL-length limits, or when posting array-shaped
        `bbox` / `collections` values.
      tags: [STAC Catalog]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StacSearchBody" }
            example:
              bbox:
                - -100.0
                - 31.0
                - -99.5
                - 32.0
              datetime: "2026-04-01T00:00:00Z/2026-04-30T23:59:59Z"
              collections:
                - "plume_aermod__oilgas"
              limit: 50
      responses:
        "200":
          description: STAC Item FeatureCollection (paged).
          content:
            application/geo+json:
              schema: { $ref: "#/components/schemas/StacItemCollection" }
              example:
                type: "FeatureCollection"
                features:
                  - type: "Feature"
                    stac_version: "1.0.0"
                    id: "job_01hx2a3f9k8qmzn7p"
                    collection: "plume_aermod__oilgas"
                    geometry:
                      type: "Polygon"
                      coordinates:
                        - - - -99.9050
                            - 31.9645
                          - - -99.9020
                            - 31.9648
                          - - -99.9018
                            - 31.9670
                          - - -99.9048
                            - 31.9667
                          - - -99.9050
                            - 31.9645
                    bbox:
                      - -99.9050
                      - 31.9645
                      - -99.9018
                      - 31.9670
                    properties:
                      datetime: "2026-04-21T03:14:22Z"
                      "geog:engine": "aermod"
                      "geog:vertical_id": "oilgas"
                      "geog:status": "complete"
                    assets: {}
                    links:
                      - rel: "self"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas/items/job_01hx2a3f9k8qmzn7p"
                        type: "application/geo+json"
                      - rel: "parent"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                      - rel: "collection"
                        href: "https://api.geog.ai/v1/stac/collections/plume_aermod__oilgas"
                        type: "application/json"
                links:
                  - rel: "self"
                    href: "https://api.geog.ai/v1/stac/search"
                    type: "application/geo+json"
                  - rel: "root"
                    href: "https://api.geog.ai/v1/stac/"
                    type: "application/json"
                numberMatched: 1
                numberReturned: 1
      security:
        - BearerAuth: []
