openapi: 3.1.0
info:
  title: Open District API
  version: 1.0.0
  description: |
    Open District connects public data, enterprise systems, and personal context
    in one secure platform. Every fact has a source. Every answer has citations.

    ## Surfaces

    - **REST** — Health check at `https://app.opendistrict.org/api/health`
    - **MCP** — Streamable HTTP at `https://mcp.opendistrict.org/mcp` (JSON-RPC 2.0)

    ## Authentication

    REST health is public. All MCP tools require a Bearer token issued via
    scoped API key at [app.opendistrict.org](https://app.opendistrict.org)
    (`POST /api/keys`).

    `POST /api/keys` accepts exactly seven scope strings and rejects anything
    else with `400 invalid scope`: `data:read`, `query:run`, `vectors:search`,
    `events:write`, `pipeline:run`, `users:manage`, `apikeys:manage`. For a
    read-only MCP client mint `data:read` + `query:run` + `vectors:search`.
    Each `tools/call` is scope-checked individually; a key missing the required
    scope gets an `isError: true` tool result, not an HTTP 403. The
    scope -> tool map is in [/agents.md](/agents.md#mcp-endpoint--config) and in
    `ci/mcp-protocol.snapshot.json`.

    ## Machine-readable agent instructions

    See [/agents.md](/agents.md) for LLM-native integration guide.
  contact:
    name: Open District
    email: hello@opendistrict.org
    url: https://opendistrict.org
  license:
    name: Proprietary
    url: https://app.opendistrict.org/legal/terms

servers:
  - url: https://app.opendistrict.org
    description: Application server (REST)
  - url: https://mcp.opendistrict.org
    description: MCP server (Streamable HTTP / JSON-RPC)

tags:
  - name: REST
    description: Public REST endpoints
  - name: MCP
    description: Model Context Protocol tools via JSON-RPC 2.0

paths:
  /api/health:
    get:
      tags: [REST]
      summary: Readiness check
      description: |
        **Readiness**, not liveness. Served by the app frontend
        (`frontend/src/routes/api/health/+server.ts`), which proxies api-gateway's
        `GET /ready`: it probes the hard data dependency (Postgres, `SELECT 1`,
        result cached 1s) and reports the soft dependency (valkey-security, the
        session-revocation denylist) without letting it gate the status code —
        revocation fails open by design, so a Valkey outage must not pull the pod
        from rotation. No authentication required.

        Use `/api/health/live` for the cheap liveness signal.
      operationId: getHealth
      responses:
        "200":
          description: Ready — Postgres answered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              example:
                status: ready
                service: api-gateway
                version: "1.0.0"
                checks:
                  postgres: ok
                  valkey: ok
        "502":
          description: "api-gateway answered but is not ready (`status: not_ready`)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              example:
                code: 502
                message: "Backend not ready (status 503)"
                details:
                  upstream_status: 503
        "503":
          description: api-gateway unreachable (timeout or connection failure)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              example:
                code: 503
                message: Backend unreachable
                details:
                  backend: unreachable
                  error: "The operation was aborted due to timeout"

  /api/health/live:
    get:
      tags: [REST]
      summary: Liveness check
      description: |
        **Liveness**: cheap, always 200 while the app's Node process is alive.
        Performs NO dependency probing, on purpose — a slow dependency probe on a
        liveness route makes the kubelet restart-loop the pod. This is what a
        Kubernetes `livenessProbe` should hit; use `/api/health` for the
        `readinessProbe`. No authentication required.
      operationId: getHealthLive
      responses:
        "200":
          description: Process alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    const: alive
                required: [status]
              example:
                status: alive

  /mcp:
    post:
      tags: [MCP]
      summary: MCP Streamable HTTP endpoint
      description: |
        JSON-RPC 2.0 endpoint implementing the Model Context Protocol over
        Streamable HTTP. Dispatches exactly six methods: `initialize`, `ping`,
        `tools/list`, `tools/call`, `prompts/list`, `prompts/get`. Anything else
        returns a JSON-RPC method-not-found error.

        `initialize` answers with `protocolVersion: "2025-06-18"` and
        `serverInfo.name: "udp-mcp"` **verbatim** — the server does not
        negotiate the protocol version, it returns its own constant whatever the
        client requested — and advertises `tools`, `prompts` and `logging`
        capabilities.

        All requests require an `Authorization: Bearer <token>` header.
        Unauthenticated requests get HTTP 401
        `{"error":"unauthorized","detail":"missing or invalid API key (Authorization: Bearer <key>)"}`.

        ## Available Tools

        | # | Tool | Description |
        |---|------|-------------|
        | 1 | `run_sql` | Execute a read-only SQL SELECT over the data platform (allowlisted tables only). |
        | 2 | `vector_search` | Semantic search over events using vector embeddings (Qdrant, mxbai-embed-large-v1, 1024-dim). |
        | 3 | `get_schema` | Inspect the queryable schema — the tables and columns run_sql can SELECT from. |
        | 4 | `catalog` | The semantic data catalog — curated table/column descriptions plus example questions and SQL. |
        | 5 | `unified_search` | Federated semantic + lexical search across the whole platform (Qdrant, ClickHouse, Postgres FTS). |
        | 6 | `metrics` | Tenant-level summary metrics: total events, events today, active sources, active pipelines. |
        | 7 | `list_sources` | List the tenant's data sources (name, connector_type, status, event_type, last_sync_at, last_error). |
        | 8 | `list_pipelines` | List the tenant's pipelines (name, status, schedule, source, destination, total_runs, failed_runs). |
        | 9 | `get_topology` | Return the tenant's flow topology graph (sources/flows nodes + edges). |
        | 10 | `entity_path` | Find how two entities are connected in the entity graph (up to 3 cited paths of at most 3 hops). |

        These are the exact `tools/list` names, generated from
        `backend/crates/api-gateway/src/mcp_server.rs` `tool_list()`.
        Four **prompts** are also exposed via `prompts/list`: follow-the-money,
        scan-the-courts, meeting-brief, thesis-watch. Prompts are not tools.

        See `x-mcp-tools` below for each tool's `inputSchema`. The server
        declares **no output schema** for any tool, so none is documented here:
        every `tools/call` result is `{"content":[{"type":"text","text":"..."}],
        "isError":bool}` and the text payload is a server-formatted string.
        What each tool returns is described in prose in its own `description`.
        Each tool also requires a specific API-key scope — see the
        Authentication section above.
      operationId: mcpEndpoint
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JsonRpcRequest"
            examples:
              toolsList:
                summary: List available tools
                value:
                  jsonrpc: "2.0"
                  id: 1
                  method: tools/list
              toolsCall:
                summary: Call unified_search (scope vectors:search)
                value:
                  jsonrpc: "2.0"
                  id: 2
                  method: tools/call
                  params:
                    name: unified_search
                    arguments:
                      query: "zoning permits downtown"
                      limit: 10
      responses:
        "200":
          description: JSON-RPC response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JsonRpcResponse"
              examples:
                toolsListResponse:
                  summary: Tools list response (first of ten entries, verbatim shape)
                  value:
                    jsonrpc: "2.0"
                    id: 1
                    result:
                      tools:
                        - name: vector_search
                          description: Semantic search over events using vector embeddings (Qdrant, mxbai-embed-large-v1, 1024-dim). Use this for meaning-based search rather than exact text matching. Returns events ranked by semantic similarity.
                          inputSchema:
                            type: object
                            properties:
                              query: { type: string, description: Natural-language search query }
                              top_k: { type: integer, description: Number of results, default: 10 }
                            required: [query]
                toolsCallResponse:
                  summary: Tool call response envelope
                  value:
                    jsonrpc: "2.0"
                    id: 2
                    result:
                      content:
                        - type: text
                          text: "<server-formatted string; no output schema is declared>"
                      isError: false
                scopeError:
                  summary: Missing scope (HTTP 200 — the error is in-band)
                  value:
                    jsonrpc: "2.0"
                    id: 2
                    result:
                      content:
                        - type: text
                          text: "Error: missing required scope 'vectors:search' for tool 'unified_search'"
                      isError: true
        "401":
          description: Missing or invalid Bearer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JsonRpcError"
        "400":
          description: Invalid JSON-RPC request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JsonRpcError"

x-mcp-tools:
  # Generated from backend/crates/api-gateway/src/mcp_server.rs `tool_list()` —
  # the authoritative `tools/list` response. Do not hand-edit names or params;
  # regenerate. `scripts/validate.sh` cross-checks these names against index.md,
  # agents.md and SKILL.md.
  #
  # `returns` is intentionally OMITTED: the server declares only `inputSchema` for
  # each tool, so a returns block here would be invented rather than sourced. The
  # previous revision of this file carried fabricated `returns` schemas for ten
  # tools that did not exist.
  - name: run_sql
    description: |
      Execute a read-only SQL SELECT query over the data platform. Available tables:
      'events'/'pg_events' (Postgres), 'ch_events' (ClickHouse), 'audit_log'. Cross-store
      federation is supported via DataFusion. Governance-enforced: SELECT-only, allowlisted
      tables only. NOTE: 'payload' is a JSON STRING column — to read a field's value use
      jsonExtractString(payload, 'key') (e.g. jsonExtractString(payload,
      'kismet_device_base_type')); to filter on a key use payload ILIKE '%"key"%' (pushes
      down). Do NOT use payload->>'key', payload->'key', or payload::jsonb — DataFusion cannot
      plan those. A bare LIMIT n returns the n most-recent rows (fast, index-backed); avoid
      ORDER BY created_at ... LIMIT over raw rows — that ordering cannot be pushed down and
      forces a full-table sort; use a bare LIMIT instead.
    parameters:
      type: object
      properties:
        sql:
          type: string
          description: |
            A read-only SELECT query (e.g. SELECT event_type, count(*) FROM events GROUP BY
            event_type LIMIT 10)
      required: [sql]

  - name: vector_search
    description: |
      Semantic search over events using vector embeddings (Qdrant, mxbai-embed-large-v1,
      1024-dim). Use this for meaning-based search rather than exact text matching. Returns
      events ranked by semantic similarity.
    parameters:
      type: object
      properties:
        query:
          type: string
          description: |
            Natural-language search query
        top_k:
          type: integer
          description: |
            Number of results
          default: 10
      required: [query]

  - name: get_schema
    description: |
      Inspect the queryable schema — the tables and columns the run_sql tool can SELECT from.
      Call this first if you are unsure what data is available.
    parameters:
      type: object
      properties: {}

  - name: catalog
    description: |
      The semantic data catalog — curated descriptions of every queryable table and column,
      plus example questions and their SQL. Use this to learn what data exists and how to
      phrase queries before calling run_sql. System-level reference data (identical for all
      tenants). No arguments.
    parameters:
      type: object
      properties: {}

  - name: unified_search
    description: |
      Federated semantic + lexical search across the whole platform (Qdrant vectors,
      ClickHouse events, Postgres full-text fallback). Returns heterogeneous ranked results.
      Prefer this over vector_search when you want broad coverage, not just embeddings.
    parameters:
      type: object
      properties:
        query:
          type: string
          description: |
            Natural-language search query
        limit:
          type: integer
          description: |
            Max results
          default: 20
        event_type:
          type: string
          description: |
            Optional event_type filter
        source_id:
          type: string
          description: |
            Optional source UUID filter
        score_threshold:
          type: number
          description: |
            Optional minimum score (0.0-1.0)
      required: [query]

  - name: metrics
    description: |
      Tenant-level summary metrics: total events, events today, active sources, active
      pipelines. No arguments.
    parameters:
      type: object
      properties: {}

  - name: list_sources
    description: |
      List the tenant's data sources (name, connector_type, status, event_type, last_sync_at,
      last_error). Up to 50 rows. No arguments.
    parameters:
      type: object
      properties: {}

  - name: list_pipelines
    description: |
      List the tenant's pipelines (name, status, schedule, source, destination, total_runs,
      failed_runs). No arguments.
    parameters:
      type: object
      properties: {}

  - name: get_topology
    description: |
      Return the tenant's flow topology graph (sources/flows nodes + edges). Use this to
      understand how data moves through the tenant's pipelines. No arguments.
    parameters:
      type: object
      properties: {}

  - name: entity_path
    description: |
      Find how two entities are connected in the entity graph. Pass two entity NAMES (or IDs
      like MACs / CVE ids) — resolution happens server-side, no separate lookup call needed.
      Returns up to 3 paths of at most 3 hops; every hop cites its source event id and
      observation date; path confidence is the minimum edge score. Distinct negatives: 'could
      not resolve "<name>"' (name not found), 'no path within 3 hops' (both entities exist but
      are not connected), 'lookup timed out' (the graph query exceeded its ~2s budget).
    parameters:
      type: object
      properties:
        from:
          type: string
          description: |
            First entity name or id (e.g. 'Xerox Corporation', 'AA:BB:CC:DD:EE:FF',
            'CVE-2024-1234')
        to:
          type: string
          description: |
            Second entity name or id
      required: [from, to]

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: Scoped API key issued at app.opendistrict.org

  schemas:
    HealthResponse:
      # Mirrors api-gateway's `ReadinessHealth` (backend/crates/api-gateway/src/
      # schema.rs) as returned by GET /api/health. `status` has exactly two
      # values; "degraded" and "down" are never emitted by any code path. This
      # schema lists every field the endpoint returns and no others — an earlier
      # revision invented an uptime property that the service does not send.
      type: object
      properties:
        status:
          type: string
          enum: [ready, not_ready]
          description: '"ready" with HTTP 200, or "not_ready" with HTTP 503.'
        service:
          type: string
          description: Always "api-gateway".
        version:
          type: string
          description: Build version of the responding api-gateway.
        checks:
          type: object
          description: |
            Per-dependency state. `postgres` carries the reason string that
            drives the status code ("ok" when ready); `valkey` reports the
            valkey-security revocation denylist and never gates the status.
            Connection strings are never included.
          additionalProperties:
            type: string
        warnings:
          type: array
          items:
            type: string
          description: |
            Non-fatal degradation notes (e.g. valkey-security unreachable, so
            session revocation fails open). Omitted entirely when empty, so the
            healthy response keeps its exact shape.
      required: [status, service, version, checks]

    ApiErrorBody:
      # The app frontend's error envelope (`jsonError` in
      # frontend/src/lib/server/api-mode.ts), used for the 502/503 paths of
      # GET /api/health.
      type: object
      properties:
        code:
          type: integer
          description: Same value as the HTTP status.
        message:
          type: string
        details:
          description: Error-specific context; may be null.
      required: [code, message]

    JsonRpcRequest:
      type: object
      properties:
        jsonrpc:
          type: string
          const: "2.0"
        id:
          oneOf:
            - type: integer
            - type: string
        method:
          type: string
          # Exactly the arms of `dispatch()`'s `match method` in
          # backend/crates/api-gateway/src/mcp_server.rs. Asserted against
          # ci/mcp-protocol.snapshot.json by scripts/validate.sh.
          enum: [initialize, ping, tools/list, tools/call, prompts/list, prompts/get]
        params:
          type: object
          properties:
            name:
              type: string
              description: Tool name (required for tools/call)
            arguments:
              type: object
              description: Tool arguments (required for tools/call)
      required: [jsonrpc, id, method]

    JsonRpcResponse:
      type: object
      properties:
        jsonrpc:
          type: string
          const: "2.0"
        id:
          oneOf:
            - type: integer
            - type: string
        result:
          type: object
          description: Present on success
        error:
          $ref: "#/components/schemas/JsonRpcErrorObject"
      required: [jsonrpc, id]

    JsonRpcError:
      type: object
      properties:
        jsonrpc:
          type: string
          const: "2.0"
        id:
          oneOf:
            - type: integer
            - type: string
        error:
          $ref: "#/components/schemas/JsonRpcErrorObject"
      required: [jsonrpc, id, error]

    JsonRpcErrorObject:
      type: object
      properties:
        code:
          type: integer
          description: JSON-RPC error code (-32600 to -32603, or custom)
        message:
          type: string
        data:
          description: Additional error data
      required: [code, message]
