openapi: 3.1.0
info:
  title: FermiHDI HD Query Proxy
  version: 2.0.2
  summary: The query front door of an HD instance.
  description: |
    The Proxy is where a client asks an HD instance a question. It authorises the
    caller against the deployment's policy, rewrites the query rather than
    refusing it where policy restricts what may be seen, scatters it to every
    Storage Cluster Controller in parallel, and reports which batches came back.

    Two things about this API are unusual enough to state up front, because a
    client written against the obvious assumptions will not work.

    **The records do not come back through this API.** A query response is a
    manifest: which batch ids were found, which were missing, and how each SCC
    fared. The records themselves are streamed by the Storage Nodes directly to
    the client over the data plane (FHDWP), to the address and port the client
    puts in `target_dps`. A network that lets a client reach only the Proxy will
    see every query answered with an empty result and no error.

    **A query response is Server-Sent Events, not a JSON body.** The HTTP status
    is 200 as soon as the request is accepted; what happened is carried by the
    events that follow. Partial success is `overall_status: partial_success`
    inside the `result` event, not an HTTP 207 -- the status line is already sent
    by the time the first SCC answers.

    Identity arrives in `X-Forwarded-*` headers set by a forward-auth service in
    front of this one. The Proxy authenticates nobody itself; it evaluates policy
    against those headers, and refuses any request without
    `X-Forwarded-User`.

servers:
  - url: https://{host}:6986
    description: Northbound, mTLS. The primary listener.
    variables:
      host:
        default: proxy
  - url: https://{host}:443
    description: Northbound alternate, for ingress that will not talk to 6986.
    variables:
      host:
        default: proxy
  - url: http://{host}:8080
    description: |
      Observability and configuration. Plain HTTP, never exposed outside the
      deployment: it carries /health, /metrics and the DMS configuration
      webhook.
    variables:
      host:
        default: proxy

tags:
  - name: query
    description: Asking the instance a question.
  - name: schema
    description: What the records look like.
  - name: operations
    description: Health, metrics and configuration.

paths:
  /v1/hd/query:
    post:
      tags: [query]
      summary: Execute a policy-governed query
      operationId: executeQuery
      description: |
        Evaluates the caller's policy, rewrites the query to match what they are
        allowed to see, and dispatches it to every SCC named in the routing
        table the DMS supplied.

        Policy does not reject a restricted query. Fields the caller may not see
        are overwritten with null bytes so they cannot match, enforced filters
        are appended, and a policy-imposed priority replaces `query_qos`. The
        caller gets an answer to the question they were allowed to ask.
      security:
        - forwardAuth: []
      parameters:
        - $ref: '#/components/parameters/ForwardedUser'
        - $ref: '#/components/parameters/ForwardedEmail'
        - $ref: '#/components/parameters/ForwardedPreferredUsername'
        - $ref: '#/components/parameters/ForwardedGroups'
        - $ref: '#/components/parameters/ForwardedRole'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QueryRequest'
      responses:
        '200':
          description: |
            The query was accepted and the event stream has begun. Whether it
            succeeded is in the events, not in this status.
          headers:
            Cache-Control:
              schema: { type: string, const: no-cache }
            Connection:
              schema: { type: string, const: keep-alive }
          content:
            text/event-stream:
              schema:
                $ref: '#/components/schemas/QueryEventStream'
              examples:
                complete:
                  summary: Every SCC answered
                  value: |
                    event: ack
                    data: {}

                    event: result
                    data: {"overall_status":"success","responses":[{"scc":"sc-1","status":200}],"found_batches":["3f2b…"],"missing_batches":[]}
                partial:
                  summary: One SCC timed out — still a 200
                  value: |
                    event: ack
                    data: {}

                    event: heartbeat
                    data: {}

                    event: result
                    data: {"overall_status":"partial_success","responses":[{"scc":"sc-1","status":200},{"scc":"sc-2","status":504}],"found_batches":["3f2b…"],"missing_batches":["9ae1…"]}
                chunked:
                  summary: More than 500 ids — streamed as batches events
                  value: |
                    event: ack
                    data: {}

                    event: batches
                    data: {"found":["3f2b…","7c04…"]}

                    event: batches
                    data: {"missing":["9ae1…"]}

                    event: result
                    data: {"overall_status":"success","responses":[{"scc":"sc-1","status":200}],"chunked":true,"found_count":1284,"missing_count":3}
        '400':
          description: The body is not valid JSON.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error_code: ERR_INVALID_JSON, message: Invalid JSON payload }
        '401':
          description: No `X-Forwarded-User`. The forward-auth service in front did not run, or did not set it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error_code: ERR_UNAUTHORIZED, message: 'Unauthorized: Missing X-Forwarded-User header' }
        '403':
          description: Policy refused the caller outright, as opposed to restricting what they may see.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error_code: ERR_FORBIDDEN, message: Forbidden by PBAC Policy }
        '405':
          description: Anything other than POST on this path.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500':
          description: Mask injection failed, the body could not be read, or the response writer cannot stream.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error_code: ERR_NO_FLUSHER, message: Streaming unsupported }
        '503':
          description: |
            The policy matrix has not compiled yet. The Proxy is up but has not
            finished registering with the DMS or building its evaluator; retry.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error_code: ERR_NOT_READY, message: Proxy evaluation matrix not yet compiled }

  /v1/hd/schema:
    get:
      tags: [schema]
      summary: The active dataset schema
      operationId: getSchema
      description: |
        The schema the instance is storing, as the DMS supplied it at
        registration. A client needs it to know which fields exist and what
        widths they are before composing a query.
      security:
        - forwardAuth: []
      responses:
        '200':
          description: The dataset schema.
          content:
            application/json:
              schema:
                type: object
                description: See schemas/fermihdi_dataset_schema.json in this repository.

  /v1/proxy/config:
    post:
      tags: [operations]
      summary: Apply configuration pushed by the DMS
      operationId: applyDynamicConfig
      description: |
        The DMS calls this when the routing table or the policy set changes. The
        new evaluator is compiled and swapped in atomically, so queries in
        flight finish against the ruleset they started with.

        Served on the plain-HTTP listener and authorised by client certificate:
        the caller must present one issued by the deployment's DMS-CA.
      security:
        - dmsClientCertificate: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DynamicConfig'
      responses:
        '200':
          description: Applied.
        '400':
          description: The payload is missing or malformed.
        '403':
          description: No DMS-issued client certificate.
        '405':
          description: Anything other than POST.

  /health:
    get:
      tags: [operations]
      summary: Liveness
      operationId: getHealth
      responses:
        '200':
          description: The process is up. It says nothing about whether the policy matrix has compiled — a query returns 503 for that.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  timestamp: { type: number, description: Unix seconds, fractional. }

  /metrics:
    get:
      tags: [operations]
      summary: Prometheus metrics
      operationId: getMetrics
      responses:
        '200':
          description: Prometheus text exposition, including queries processed and bytes received.
          content:
            text/plain: {}

components:
  securitySchemes:
    forwardAuth:
      type: apiKey
      in: header
      name: X-Forwarded-User
      description: |
        Not a credential the caller chooses. A forward-auth service in front of
        the Proxy authenticates the request and sets the X-Forwarded-* headers;
        the Proxy trusts them and evaluates policy against them. Nothing but that
        service should be able to reach this API.
    dmsClientCertificate:
      type: mutualTLS
      description: A client certificate issued by this deployment's DMS-CA.

  parameters:
    ForwardedUser:
      name: X-Forwarded-User
      in: header
      required: true
      schema: { type: string }
      description: The caller. Required; its absence is a 401.
    ForwardedEmail:
      name: X-Forwarded-Email
      in: header
      required: false
      schema: { type: string, format: email }
    ForwardedPreferredUsername:
      name: X-Forwarded-Preferred-Username
      in: header
      required: false
      schema: { type: string }
    ForwardedGroups:
      name: X-Forwarded-Groups
      in: header
      required: false
      schema: { type: string }
      description: Comma-separated.
    ForwardedRole:
      name: X-Forwarded-Role
      in: header
      required: false
      schema: { type: string }

  schemas:
    BatchId:
      type: string
      pattern: '^[0-9a-f]{128}$'
      description: A 64-byte batch identifier, hex-encoded.

    QueryRequest:
      type: object
      description: |
        The full query grammar is schemas/fermihdi_query_schema.json in this
        repository. What matters at this layer is below; everything else is
        passed through to the SCCs after policy has rewritten it.
      required: [sub_queries]
      properties:
        sub_queries:
          type: array
          minItems: 1
          description: The sub-queries to evaluate. Policy may narrow these before dispatch.
          items:
            type: object
        target_dps:
          type: object
          description: |
            Where the Storage Nodes should stream the records. This is the
            client's own FHDWP listener, not the Proxy's -- results never come
            back through this API. Injected with a default port of 6987 when
            absent.
          properties:
            ip:
              type: string
              description: IPv4 or IPv6 address of the client's FHDWP receive listener.
            port:
              type: integer
              default: 6987
        query_qos:
          type: integer
          description: Requested priority. Policy may override it.
        timeout_ms:
          type: integer
          description: How long to wait for the SCCs before reporting what has been collected.

    QueryEventStream:
      type: string
      description: |
        A `text/event-stream` carrying, in order:

          ack        sent immediately, so a client knows it was accepted
          heartbeat  periodic, while the SCCs are still answering
          batches    only when there are more than 500 ids in total; each event
                     carries a `found` or `missing` array, and the result event
                     then reports counts instead of the ids
          result     exactly one, and always last

        The `result` event's data is a QueryResult.

    QueryResult:
      type: object
      required: [overall_status, responses]
      properties:
        overall_status:
          type: string
          enum: [success, partial_success]
          description: |
            partial_success means at least one SCC timed out or failed. The ids
            that did come back are still in the response, and are still valid.
        responses:
          type: array
          description: One entry per SCC the query was dispatched to.
          items:
            $ref: '#/components/schemas/SCCResponse'
        found_batches:
          type: array
          items: { $ref: '#/components/schemas/BatchId' }
          description: Present unless the response was chunked.
        missing_batches:
          type: array
          items: { $ref: '#/components/schemas/BatchId' }
          description: Present unless the response was chunked.
        chunked:
          type: boolean
          description: Set when the ids were sent as `batches` events instead of inline, above 500 ids in total.
        found_count:
          type: integer
          description: Present when chunked.
        missing_count:
          type: integer
          description: Present when chunked.

    SCCResponse:
      type: object
      description: How one Storage Cluster Controller fared.
      properties:
        scc: { type: string, description: The Storage Cluster's name. }
        status: { type: integer, description: 'HTTP-equivalent status: 200 answered, 504 timed out, 500 failed.' }

    DynamicConfig:
      type: object
      description: What the DMS pushes when the deployment changes.
      properties:
        sc_routing_table:
          type: object
          description: Storage Cluster name to the URLs of its SCCs.
          additionalProperties:
            type: array
            items: { type: string, format: uri }
        pbac_policies:
          type: array
          description: The policy set, compiled into the evaluator that replaces the running one.
          items: { type: object }
        opt_in:
          type: boolean
          description: Whether this node participates in FermiHDI product telemetry.
        sentry_dsn:
          type: string
          description: Where to report errors, if anywhere.

    Error:
      type: object
      required: [error_code, message]
      properties:
        error_code:
          type: string
          enum: [ERR_INVALID_JSON, ERR_UNAUTHORIZED, ERR_FORBIDDEN, ERR_METHOD_NOT_ALLOWED, ERR_INTERNAL, ERR_NO_FLUSHER, ERR_NOT_READY]
        message:
          type: string
