openapi: 3.1.0
info:
  title: postgr.esq HTTP API
  version: "0.1.0"
  summary: REST surface served by Agora at https://postgr.esq
  description: |
    postgr.esq exposes a public-inbox–compatible HTTP API for PostgreSQL
    community mailing lists, plus a small set of operational endpoints
    (`/healthz`, `/mcp/`). The same URLs typically render either HTML or
    JSON depending on the `format` query parameter or the `Accept` header.

    This spec is generated from the route table in
    `pkg/protocol/http/router.go` and the JSON handlers in
    `pkg/protocol/http/api.go`. Endpoints flagged with `x-status: pending`
    are known to be broken at the time of writing; everything else is a
    live route confirmed by curl probes against https://postgr.esq.

    The service is read-only. The only POST endpoints documented here are
    git smart-HTTP fetches and the MCP JSON-RPC envelope.
  license:
    name: AGPL-3.0-or-later
    identifier: AGPL-3.0-or-later
  contact:
    name: postgr.esq
    url: https://postgr.esq/contact
servers:
  - url: https://postgr.esq
    description: Production
tags:
  - name: search
    description: Inbox search and listing
  - name: messages
    description: Single-message and thread retrieval
  - name: feeds
    description: Atom and RSS feeds
  - name: inbox
    description: Inbox metadata and informational pages
  - name: git
    description: Git smart-HTTP transport (public-inbox v2 layout)
  - name: ops
    description: Health and MCP control plane

paths:

  # ---------------- search ----------------
  /m/{inbox}/:
    get:
      tags: [search]
      operationId: searchInbox
      summary: Inbox search (HTML or JSON)
      description: |
        Query a single inbox. The same URL serves the HTML search results
        page that browsers see and the JSON envelope that programmatic
        clients see; the discriminator is `format=json`.

        At least one of `q`, `subject`, `from`, `mid`, `after`, or
        `before` is required for the JSON variant. Without any filter
        parameters the HTML variant renders the inbox index instead of a
        search.

        Pagination is reported in the JSON body (`offset`, `limit`,
        `has_more`, `total`) and via RFC 8288 `Link` headers
        (`rel="first"`, `rel="prev"`, `rel="next"`).
      parameters:
        - $ref: '#/components/parameters/Inbox'
        - name: q
          in: query
          description: |
            Full-text query. By default this is BM25 over Subject + body.
            If `regex=1`, `q` is treated as a TRE regular expression
            evaluated by the `pg_tre` extension.
          schema: { type: string }
          example: numa
        - name: subject
          in: query
          description: Filter on the Subject header (BM25).
          schema: { type: string }
        - name: from
          in: query
          description: Filter on the From header (BM25).
          schema: { type: string }
          example: greg
        - name: mid
          in: query
          description: Exact Message-ID lookup. Surrounding angle brackets are stripped.
          schema: { type: string }
        - name: after
          in: query
          description: Lower bound on Date. Accepts `YYYY-MM-DD`, RFC 3339, or unix seconds.
          schema:
            oneOf:
              - { type: string, format: date }
              - { type: string, format: date-time }
              - { type: integer, format: int64 }
          example: "2026-01-01"
        - name: before
          in: query
          description: Upper bound on Date (exclusive). Same formats as `after`.
          schema:
            oneOf:
              - { type: string, format: date }
              - { type: string, format: date-time }
              - { type: integer, format: int64 }
          example: "2026-02-01"
        - name: regex
          in: query
          description: |
            Set to `1` or `true` to interpret `q` as a TRE regex via
            `pg_tre`. Must be combined with `q`.
          schema:
            type: string
            enum: ["0", "1", "true", "false"]
        - name: k
          in: query
          description: |
            Edit distance for fuzzy regex matching. `0` means exact match;
            `1` and above allow that many typos. Clamped to `[0, 5]`.
            Only meaningful when `regex=1`.
          schema:
            type: integer
            minimum: 0
            maximum: 5
            default: 0
        - name: o
          in: query
          description: Result offset (zero-based). Default `0`.
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: limit
          in: query
          description: Page size. Default `50`, server-side max `1000`.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 50
        - name: format
          in: query
          description: |
            Set to `json` to receive `application/json`. Any other value
            (or an absent parameter) renders HTML.
          schema:
            type: string
            enum: [json]
      responses:
        "200":
          description: Search results (or inbox index in HTML if no filters were given).
          headers:
            Link:
              description: |
                RFC 8288 pagination links. Always includes `rel="first"`;
                includes `rel="prev"` when `offset > 0` and `rel="next"`
                when more results exist.
              schema: { type: string }
              example: '</m/pgsql-hackers/?format=json&limit=2&q=numa>; rel="first", </m/pgsql-hackers/?format=json&limit=2&o=2&q=numa>; rel="next"'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResponse' }
              example:
                query: numa
                total: 75
                offset: 0
                limit: 2
                has_more: true
                results:
                  - inbox: pgsql-hackers
                    subject: "Re: Should IGNORE NULLS cache nullness for volatile arguments?"
                    from: "Chao Li <li.evan.chao@gmail.com>"
                    date: "2026-05-15T03:08:54Z"
                    date_unix: 1778814534
                    message_id: FB71EF4E-AFBC-4658-8A3D-2F29F7CFEB29@gmail.com
                    thread_url: /m/pgsql-hackers/FB71EF4E-AFBC-4658-8A3D-2F29F7CFEB29@gmail.com/t/
                    message_url: /m/pgsql-hackers/FB71EF4E-AFBC-4658-8A3D-2F29F7CFEB29@gmail.com/
                    relevance: 0
                    blob: 20fc06fcab9afea63ddb63ab9ba38e66f9447823
            text/html:
              schema: { type: string }
        "400":
          $ref: '#/components/responses/JSONError'
        "404":
          $ref: '#/components/responses/JSONError'
        "503":
          $ref: '#/components/responses/JSONError'

  # ---------------- inbox metadata ----------------
  /m/{inbox}/info.json:
    get:
      tags: [inbox]
      operationId: getInboxInfo
      summary: Inbox metadata (JSON)
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: Inbox metadata.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Inbox' }
              example:
                name: pgsql-hackers
                description: public inbox for pgsql-hackers@postgresql.org
                address: pgsql-hackers@postgresql.org
                addresses: [pgsql-hackers@postgresql.org]
                url: https://postgr.esq/l/pgsql-hackers/pgsql-hackers/
                message_count: 86034
                last_modified: "2026-05-19T06:05:50Z"
        "404":
          $ref: '#/components/responses/JSONError'

  # ---------------- single message ----------------
  /m/{inbox}/{message_id}/:
    get:
      tags: [messages]
      operationId: getMessageHTML
      summary: Single message page (HTML)
      description: |
        Renders the public-inbox-style single-message view. The
        `{message_id}` path component is URL-encoded; message-IDs that
        contain `/` (common in postgresql.org IDs) must percent-encode
        them as `%2F`.
      parameters:
        - $ref: '#/components/parameters/Inbox'
        - $ref: '#/components/parameters/MessageID'
      responses:
        "200":
          description: HTML message page.
          content:
            text/html:
              schema: { type: string }
        "404":
          description: Message not found.

  /m/{inbox}/{message_id}/info.json:
    get:
      tags: [messages]
      operationId: getMessageInfo
      summary: Single message metadata + body (JSON)
      parameters:
        - $ref: '#/components/parameters/Inbox'
        - $ref: '#/components/parameters/MessageID'
      responses:
        "200":
          description: Message envelope plus extracted body and attachment list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
              example:
                message_id: FB71EF4E-AFBC-4658-8A3D-2F29F7CFEB29@gmail.com
                subject: "Re: Should IGNORE NULLS cache nullness for volatile arguments?"
                from: "Chao Li <li.evan.chao@gmail.com>"
                date: "2026-05-15T03:08:54Z"
                date_unix: 1778814534
                blob: 20fc06fcab9afea63ddb63ab9ba38e66f9447823
                bytes: 0
                thread_id: 21048
                article_num: 326904
                in_reply_to:
                  - 20260514.215622.2237422742023167912.ishii@postgresql.org
                references:
                  - 42B42506-6972-4266-8422-FB73E61D9DA7@gmail.com
                  - 20260514.165323.1913944269609196803.ishii@postgresql.org
                  - 20260514.215622.2237422742023167912.ishii@postgresql.org
        "404":
          $ref: '#/components/responses/JSONError'
        "503":
          $ref: '#/components/responses/JSONError'

  /m/{inbox}/{message_id}/raw:
    get:
      tags: [messages]
      operationId: getMessageRaw
      summary: Raw RFC 822 message (text/plain)
      description: |
        Returns the original message bytes as `text/plain; charset=UTF-8`
        with `Content-Disposition: inline`. The body is the unmodified
        RFC 822 source pulled from the public-inbox v2 git store.
      parameters:
        - $ref: '#/components/parameters/Inbox'
        - $ref: '#/components/parameters/MessageID'
      responses:
        "200":
          description: Raw message.
          headers:
            Content-Disposition:
              schema: { type: string }
              example: inline
          content:
            text/plain:
              schema:
                type: string
                format: rfc822
        "404":
          description: Message not found.

  /m/{inbox}/{message_id}/t/:
    get:
      tags: [messages]
      operationId: getThreadHTML
      summary: Thread view (HTML)
      description: |
        Indented thread view. The flat (`T/`) variant exists too but is
        not enumerated here; replace `t/` with `T/` for the chronological
        flat layout.
      parameters:
        - $ref: '#/components/parameters/Inbox'
        - $ref: '#/components/parameters/MessageID'
      responses:
        "200":
          description: HTML thread page.
          content:
            text/html:
              schema: { type: string }
        "404":
          description: Thread not found.

  # ---------------- feeds ----------------
  /m/{inbox}/new.atom:
    get:
      tags: [feeds]
      operationId: getInboxAtomNew
      summary: Atom feed of recent messages
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: Atom 1.0 feed.
          content:
            application/atom+xml:
              schema: { type: string, format: xml }
        "404":
          description: Inbox not found.

  /m/{inbox}/atom.xml:
    get:
      tags: [feeds]
      operationId: getInboxAtomXml
      summary: Atom feed (alias of `new.atom`)
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: Atom 1.0 feed.
          content:
            application/atom+xml:
              schema: { type: string, format: xml }

  /m/{inbox}/rss.xml:
    get:
      tags: [feeds]
      operationId: getInboxRSS
      summary: RSS 2.0 feed of recent messages
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: RSS 2.0 feed.
          content:
            application/rss+xml:
              schema: { type: string, format: xml }

  # ---------------- text help pages ----------------
  /m/{inbox}/_/text/help/:
    get:
      tags: [inbox]
      operationId: getInboxHelp
      summary: Search-syntax help page (HTML)
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: HTML help page.
          content:
            text/html:
              schema: { type: string }

  /m/{inbox}/_/text/color/:
    get:
      tags: [inbox]
      operationId: getInboxColor
      summary: Color customization page (HTML)
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: HTML color help page.
          content:
            text/html:
              schema: { type: string }

  /m/{inbox}/_/text/mirror/:
    get:
      tags: [inbox]
      operationId: getInboxMirror
      summary: Mirror instructions (HTML)
      parameters:
        - $ref: '#/components/parameters/Inbox'
      responses:
        "200":
          description: HTML mirror page.
          content:
            text/html:
              schema: { type: string }

  # ---------------- git smart-HTTP ----------------
  /m/{inbox}.git/info/refs:
    get:
      tags: [git]
      operationId: gitInfoRefs
      summary: Git smart-HTTP capability advertisement
      description: |
        Standard `git fetch`/`git clone` discovery endpoint. The `service`
        query parameter must be `git-upload-pack`; the response is a
        pkt-line stream advertising HEAD and the available refs.
      parameters:
        - $ref: '#/components/parameters/InboxGit'
        - name: service
          in: query
          required: true
          schema:
            type: string
            enum: [git-upload-pack]
      responses:
        "200":
          description: pkt-line capability advertisement.
          content:
            application/x-git-upload-pack-advertisement:
              schema: { type: string, format: binary }
              example: |
                001e# service=git-upload-pack
                0000011368f2f66f6f7fa095dea4b0d105343540d19b188f HEAD ...
        "404":
          description: Inbox or git store not found.

  /m/{inbox}.git/git-upload-pack:
    post:
      tags: [git]
      operationId: gitUploadPack
      summary: Git smart-HTTP fetch (currently broken)
      description: |
        Phase two of `git fetch`: the client posts its `want`/`have`
        negotiation pkt-line stream, the server responds with a pack.

        **This endpoint is currently broken**: live probes return
        `404 Not Found`. `git clone https://postgr.esq/m/<inbox>.git`
        therefore fails after the initial ref advertisement. The
        `info/refs` discovery handler works; the upload-pack POST
        handler does not. Tracking issue: see the public-inbox v2 git
        backend (`pkg/protocol/http/git.go`).
      x-status: pending
      x-broken: true
      parameters:
        - $ref: '#/components/parameters/InboxGit'
      requestBody:
        required: true
        content:
          application/x-git-upload-pack-request:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: pkt-line packfile stream (when fixed).
          content:
            application/x-git-upload-pack-result:
              schema: { type: string, format: binary }
        "404":
          description: Currently returned for all requests.

  # ---------------- operational ----------------
  /healthz:
    get:
      tags: [ops]
      operationId: healthz
      summary: Service health and ingestion freshness
      description: |
        Reports per-service TCP probe status (https/mcp/nntp/imap/pop3),
        rolling uptime windows (15 min / 1 h / 24 h), a blended uptime
        percentage, and PostgreSQL ingestion timestamps for inboxes,
        repositories, and other sources.

        Returns `503 Service Unavailable` when any probed service is
        currently down; the JSON body is identical in shape.
      responses:
        "200":
          description: All probed services are up.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
              example:
                ok: true
                services:
                  https: { up: true, latency_ms: 0, uptime_pct: "99.52", uptime_15m: "100.00", uptime_1h: "99.52", uptime_24h: "99.52", samples_15m: 90, samples_1h: 210, samples_24h: 210 }
                  mcp:   { up: true, latency_ms: 0, uptime_pct: "99.52", uptime_15m: "100.00", uptime_1h: "99.52", uptime_24h: "99.52", samples_15m: 90, samples_1h: 210, samples_24h: 210 }
                  nntp:  { up: true, latency_ms: 0, uptime_pct: "99.52", uptime_15m: "100.00", uptime_1h: "99.52", uptime_24h: "99.52", samples_15m: 90, samples_1h: 210, samples_24h: 210 }
                  imap:  { up: true, latency_ms: 0, uptime_pct: "99.52", uptime_15m: "100.00", uptime_1h: "99.52", uptime_24h: "99.52", samples_15m: 90, samples_1h: 210, samples_24h: 210 }
                  pop3:  { up: true, latency_ms: 0, uptime_pct: "99.52", uptime_15m: "100.00", uptime_1h: "99.52", uptime_24h: "99.52", samples_15m: 90, samples_1h: 210, samples_24h: 210 }
                blended_uptime_pct: "99.52"
                ingestion:
                  inboxes:
                    - { name: pgsql-hackers, kind: inbox, last_update: "2026-05-19T06:05:50Z", items: 86034 }
                  last_email: "2026-05-19T06:05:50Z"
        "503":
          description: At least one probed service is down.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

  /openapi.yaml:
    get:
      tags: [ops]
      operationId: getOpenAPISpec
      summary: This OpenAPI 3.1 specification
      responses:
        "200":
          description: OpenAPI 3.1 document.
          content:
            application/yaml:
              schema: { type: string }

  /api/regex:
    post:
      tags: [search]
      operationId: postRegex
      summary: Server-side regex search across messages, symbols, docs, and on-disk git repos
      description: |
        Run an RE2-compatible regular expression against one of four
        scopes:

          * `messages` — `ag_messages.subject` and `ag_messages.from_addr`
          * `symbols`  — `ag_ci_symbols.name` and `ag_ci_symbols.qualified_name`
          * `docs`     — `ag_doc_pages.path` and `ag_doc_pages.title`
          * `git`      — ripgrep against the worktree cache materialised
            from the on-disk bare repo at `/data/agora/git/<repo>.git`.
            Git objects are NOT stored in Postgres; the cache is
            refreshed (`git checkout HEAD -- .` into the worktree)
            only when the bare repo's HEAD has advanced.

        Constraints:
          * Pattern is RE2-compatible, max 1024 chars.
          * Per-call wallclock budget is 5 s (504 on miss).
          * Response is capped at 1 MiB; large result sets are returned
            with `truncated: true`.
          * Each call is logged to `public.regex_api_calls` with a SHA-256
            prefix of the pattern (raw pattern is not stored).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pattern, scope]
              properties:
                pattern: { type: string, maxLength: 1024 }
                scope:
                  type: string
                  enum: [messages, symbols, docs, git]
                inbox:  { type: string, description: 'optional, scope=messages' }
                repo:   { type: string, description: 'required, scope=git' }
                limit:  { type: integer, minimum: 1, maximum: 500, default: 50 }
      responses:
        "200":
          description: Hits in the requested scope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scope:      { type: string }
                  pattern:    { type: string }
                  inbox:      { type: string }
                  repo:       { type: string }
                  count:      { type: integer }
                  elapsed_ms: { type: integer }
                  truncated:  { type: boolean }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          properties:
                            mid:       { type: string }
                            subject:   { type: string }
                            from:      { type: string }
                            date_unix: { type: integer, format: int64 }
                        - type: object
                          properties:
                            repo:  { type: string }
                            name:  { type: string }
                            qname: { type: string }
                            kind:  { type: string }
                            path:  { type: string }
                            line:  { type: integer }
                        - type: object
                          properties:
                            path:  { type: string }
                            kind:  { type: string }
                            bytes: { type: integer, format: int64 }
                        - type: object
                          properties:
                            path:        { type: string }
                            line:        { type: integer }
                            text:        { type: string }
                            byte_offset: { type: integer }
                  stats:
                    type: object
                    description: scope=git only.
                    properties:
                      files_searched: { type: integer }
                      matches:        { type: integer }
        "400": { description: Invalid pattern, scope, or limit. }
        "404": { description: Inbox or repo not found (scope=messages with `inbox`, scope=git). }
        "503": { description: Postgres or ripgrep unavailable. }
        "504": { description: Per-call 5 s wallclock budget exhausted. }

  /mcp/:
    post:
      tags: [ops]
      operationId: mcpRPC
      summary: Model Context Protocol JSON-RPC envelope
      description: |
        Streamable-HTTP transport for the [Model Context
        Protocol](https://modelcontextprotocol.io/) (spec rev 2025-03-26).

        The request body is a single JSON-RPC 2.0 envelope. The first
        request is `initialize`; the response includes an
        `Mcp-Session-Id` header. Subsequent calls (`tools/list`,
        `tools/call`, `resources/read`, etc.) MUST echo that header.

        The HTTP layer here is a thin nginx → `agora-mcp` (`127.0.0.1:8484`)
        proxy; full protocol semantics live in the MCP server. SSE
        streaming responses are negotiated via `Accept: text/event-stream`.
      parameters:
        - name: Mcp-Session-Id
          in: header
          required: false
          description: |
            Session token returned by the prior `initialize` call.
            Required for every request after `initialize`.
          schema: { type: string }
        - name: Accept
          in: header
          required: false
          description: |
            Use `application/json` for unary responses or
            `application/json, text/event-stream` to allow SSE streaming.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JSONRPCRequest' }
            example:
              jsonrpc: "2.0"
              id: 1
              method: initialize
              params:
                protocolVersion: "2025-03-26"
                capabilities: {}
                clientInfo: { name: curl, version: "1" }
      responses:
        "200":
          description: JSON-RPC response.
          headers:
            Mcp-Session-Id:
              description: Session token; preserve for subsequent calls.
              schema: { type: string }
              example: mcp-session-c984c87b-b900-4f08-863e-47ce8507807c
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JSONRPCResponse' }
              example:
                jsonrpc: "2.0"
                id: 1
                result:
                  protocolVersion: "2025-03-26"
                  capabilities:
                    resources: { subscribe: true }
                    tools: { listChanged: true }
                  serverInfo: { name: postgr.esq, version: dev }
            text/event-stream:
              schema: { type: string }

components:
  parameters:
    Inbox:
      name: inbox
      in: path
      required: true
      description: |
        Inbox short name (e.g. `pgsql-hackers`, `pgsql-bugs`). Must match
        `[\w\-][\w\.\-\+]*`.
      schema:
        type: string
        pattern: '^[\w\-][\w\.\-\+]*$'
      example: pgsql-hackers
    InboxGit:
      name: inbox
      in: path
      required: true
      description: |
        Inbox short name. The path includes the `.git` suffix outside
        this template (e.g. `/m/pgsql-hackers.git/info/refs`).
      schema:
        type: string
        pattern: '^[\w\-][\w\.\-\+]*$'
      example: pgsql-hackers
    MessageID:
      name: message_id
      in: path
      required: true
      description: |
        RFC 5322 Message-ID *without* surrounding angle brackets,
        URL-encoded (encode `/`, `+`, etc.).
      schema:
        type: string
      example: FB71EF4E-AFBC-4658-8A3D-2F29F7CFEB29@gmail.com

  responses:
    JSONError:
      description: JSON error envelope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
      example: { error: "missing query: provide at least one of q, subject, from, mid, after, before" }

    SearchResponse:
      type: object
      required: [query, total, offset, limit, has_more, results]
      properties:
        query:   { type: string, description: Echo of the `q` parameter (empty when only structured filters were used). }
        total:   { type: integer, format: int64, minimum: 0 }
        offset:  { type: integer, minimum: 0 }
        limit:   { type: integer, minimum: 1, maximum: 1000 }
        has_more: { type: boolean }
        results:
          type: array
          items: { $ref: '#/components/schemas/SearchResult' }

    SearchResult:
      type: object
      required: [inbox, subject, from, date, date_unix, message_id, relevance]
      properties:
        inbox:       { type: string }
        subject:     { type: string }
        from:        { type: string, description: Raw From header (display name + address). }
        date:        { type: string, format: date-time }
        date_unix:   { type: integer, format: int64 }
        message_id:  { type: string }
        message_url: { type: string, description: Path to the single-message page. }
        thread_url:  { type: string, description: Path to the threaded view. }
        relevance:   { type: number, format: double, description: BM25/TRE score; `0` when ranking is not applicable (e.g. structured-filter-only queries). }
        blob:        { type: string, description: Git blob OID of the source mbox slice. }
        bytes:       { type: integer, format: int64, description: Size of the source mbox slice in bytes. }

    Inbox:
      type: object
      required: [name, description, address, url, message_count]
      properties:
        name:          { type: string }
        description:   { type: string }
        address:       { type: string, format: email }
        addresses:
          type: array
          items: { type: string, format: email }
        url:           { type: string, format: uri }
        clone_urls:
          type: array
          items: { type: string, format: uri }
        newsgroup:     { type: string }
        message_count: { type: integer, format: int64 }
        last_modified: { type: string, format: date-time }

    Message:
      type: object
      required: [message_id, subject, from, date, date_unix, blob, bytes, thread_id, article_num]
      properties:
        message_id:  { type: string }
        subject:     { type: string }
        from:        { type: string }
        to:          { type: string }
        cc:          { type: string }
        date:        { type: string, format: date-time }
        date_unix:   { type: integer, format: int64 }
        blob:        { type: string }
        bytes:       { type: integer, format: int64 }
        thread_id:   { type: integer, format: int64 }
        article_num: { type: integer, format: int64 }
        in_reply_to:
          type: array
          items: { type: string }
        references:
          type: array
          items: { type: string }
        body:
          type: string
          description: Decoded body text. May include MIME boundary headers when the message is multipart.
        attachments:
          type: array
          items: { $ref: '#/components/schemas/Attachment' }

    Attachment:
      type: object
      required: [index, filename, content_type, size]
      properties:
        index:        { type: integer, minimum: 1 }
        filename:     { type: string }
        content_type: { type: string }
        size:         { type: integer, format: int64 }

    Health:
      type: object
      required: [ok, services, blended_uptime_pct]
      properties:
        ok: { type: boolean }
        services:
          type: object
          additionalProperties: { $ref: '#/components/schemas/ServiceStatus' }
        blended_uptime_pct: { type: string, description: Formatted percentage (e.g. "99.52"). }
        ingestion: { $ref: '#/components/schemas/Ingestion' }

    ServiceStatus:
      type: object
      required: [up, latency_ms, uptime_pct]
      properties:
        up:         { type: boolean }
        latency_ms: { type: integer, format: int64 }
        uptime_pct: { type: string, description: 1-hour window, kept for backwards compatibility. }
        uptime_15m: { type: string }
        uptime_1h:  { type: string }
        uptime_24h: { type: string }
        samples_15m: { type: integer }
        samples_1h:  { type: integer }
        samples_24h: { type: integer }

    Ingestion:
      type: object
      properties:
        inboxes:
          type: array
          items: { $ref: '#/components/schemas/SourceStatus' }
        repositories:
          type: array
          items: { $ref: '#/components/schemas/SourceStatus' }
        sources:
          type: array
          items: { $ref: '#/components/schemas/SourceStatus' }
        last_email:     { type: string, format: date-time }
        last_git:       { type: string, format: date-time }
        last_docs:      { type: string, format: date-time }
        last_wiki:      { type: string, format: date-time }
        next_scheduled: { type: string, format: date-time }

    SourceStatus:
      type: object
      required: [name]
      properties:
        name:          { type: string }
        kind:          { type: string, enum: [inbox, repo, docs, wiki, discord] }
        last_update:   { type: string, format: date-time }
        next_sync:     { type: string, format: date-time }
        items:         { type: integer, format: int64 }
        latest_commit: { type: string }
        symbols:       { type: integer, format: int64 }

    JSONRPCRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, enum: ["2.0"] }
        id:
          oneOf:
            - { type: string }
            - { type: integer }
        method: { type: string, example: initialize }
        params: { type: object, additionalProperties: true }

    JSONRPCResponse:
      type: object
      required: [jsonrpc]
      properties:
        jsonrpc: { type: string, enum: ["2.0"] }
        id:
          oneOf:
            - { type: string }
            - { type: integer }
        result: { type: object, additionalProperties: true }
        error:
          type: object
          properties:
            code:    { type: integer }
            message: { type: string }
            data:    {}
