{
  "openapi": "3.0.3",
  "info": {
    "title": "Epok Public API",
    "version": "1.0.0",
    "description": "Curated public API surface for Epok.\n\nThis spec documents the stable, supported endpoints customers use programmatically. Admin, OAuth, team-management, and internal routes are intentionally excluded.\n\n## Authentication\n\nAll non-`/health` requests require a tenant-scoped API key. Three transport options:\n\n- `Authorization: Bearer <key>` header (recommended for HTTP)\n- HTTP Basic Auth (username = key, password = empty) — used by Loki-native shippers\n- `X-API-Key: <key>` header — used by some legacy clients\n\nKeys have one or more scopes: `read` (default), `write`, `ingest`. Endpoints that mutate state require `write`.\n\nBrowser sessions authenticate via secure cookies, which are not part of the public API contract.\n\n## Rate limits\n\nQuery endpoints (`/search`, `/hits`, `/facets`, `/streams`, `/patterns`) are rate-limited per API key. When limits are exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header (seconds). Ingest endpoints have separate per-tenant token-bucket limits configured by plan tier.\n\n## Errors\n\nEvery error response uses the `ErrorResponse` schema and includes an `X-Request-Id` response header for support correlation.\n\n## Pagination\n\nList endpoints return `items`, `total`, `limit`, `offset`, `has_more`. Page by incrementing `offset` by `limit` until `has_more` is `false`.\n\n## Time format\n\nAll timestamps are ISO-8601 UTC (`2026-05-14T12:00:00Z`). Query window parameters (`start`, `end`, `since`) additionally accept relative durations like `-1h`, `-15m`, `-7d`, and the literal `now`.\n\n## Servers\n\n- `https://app.getepok.dev` — API + WebSocket endpoints\n- `https://ingest.getepok.dev` — log ingestion endpoints",
    "contact": {
      "name": "Epok Support",
      "email": "support@getepok.dev",
      "url": "https://getepok.dev/docs"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://getepok.dev/terms"
    }
  },
  "servers": [
    { "url": "https://app.getepok.dev", "description": "API + WebSocket endpoints" },
    { "url": "https://ingest.getepok.dev", "description": "Log ingestion endpoints" }
  ],
  "security": [
    { "BearerAuth": [] },
    { "BasicAuth": [] },
    { "ApiKeyHeader": [] }
  ],
  "tags": [
    { "name": "Health", "description": "Unauthenticated health check." },
    { "name": "Ingest", "description": "Log ingestion endpoints on `ingest.getepok.dev`. Wire-compatible with Elasticsearch Bulk, Loki Push, OpenTelemetry HTTP, and syslog." },
    { "name": "Search", "description": "Search logs, fetch facets, fetch volume histograms, list streams." },
    { "name": "Alerts", "description": "List, inspect, and resolve alerts. All paths are tenant-scoped." },
    { "name": "Intelligence", "description": "Pattern clusters produced by the intelligence engine. Each pattern groups variant log messages under a stable fingerprint." },
    { "name": "Live", "description": "WebSocket streams for live-tail and real-time alert updates." }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": ["Health"],
        "summary": "Service health check",
        "description": "Returns 200 when the API process is running. Does not check downstream dependencies (database, log backend). Suitable for HTTP load-balancer health checks.",
        "operationId": "getHealth",
        "security": [],
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HealthResponse" },
                "example": { "status": "ok" }
              }
            }
          }
        }
      }
    },

    "/insert/elasticsearch/_bulk": {
      "post": {
        "tags": ["Ingest"],
        "summary": "Elasticsearch-compatible bulk ingest",
        "description": "Accepts NDJSON with Elasticsearch-style bulk action lines. Wire-compatible with Logstash, Filebeat, Vector's `elasticsearch` sink, Fluent Bit's `es` output, and plain curl.\n\nSend on `ingest.getepok.dev`. Each pair of lines is `{\"create\":{}}` followed by the log entry JSON.",
        "operationId": "ingestElasticsearchBulk",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-ndjson": {
              "schema": { "type": "string", "format": "ndjson" },
              "example": "{\"create\":{}}\n{\"_msg\":\"GET /api/users 200 42ms\",\"_time\":\"2026-05-14T10:00:00Z\",\"level\":\"info\",\"service\":\"api\",\"status_code\":200}\n{\"create\":{}}\n{\"_msg\":\"connection timeout\",\"_time\":\"2026-05-14T10:00:01Z\",\"level\":\"error\",\"service\":\"api\"}\n"
            }
          }
        },
        "responses": {
          "200": {
            "description": "All entries accepted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/IngestAck" },
                "example": { "took": 4, "errors": false, "items": [] }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/loki/api/v1/push": {
      "post": {
        "tags": ["Ingest"],
        "summary": "Loki push API",
        "description": "Loki-compatible push endpoint on `ingest.getepok.dev`. Wire-compatible with Promtail, Grafana Alloy, Fluent Bit `loki` output, Vector's `loki` sink, and the OpenTelemetry Loki exporter.\n\nUse HTTP Basic Auth (username = API key, password = empty) — this is what Loki shippers send by default.",
        "operationId": "ingestLokiPush",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/LokiPushBody" },
              "example": {
                "streams": [
                  {
                    "stream": { "service": "api", "host": "web-01", "level": "info" },
                    "values": [
                      ["1715680800000000000", "GET /api/users 200 42ms"],
                      ["1715680801000000000", "GET /api/orders 200 18ms"]
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "204": { "description": "Accepted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/v1/logs": {
      "post": {
        "tags": ["Ingest"],
        "summary": "OpenTelemetry HTTP logs (OTLP)",
        "description": "OpenTelemetry Protocol HTTP logs endpoint on `ingest.getepok.dev`. Use with the `otlphttp` exporter in the OpenTelemetry Collector, the `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` env var on any OTEL SDK, or any OTLP-HTTP-compatible shipper.\n\nAccepts both `application/json` (OTLP/HTTP JSON) and `application/x-protobuf` (OTLP/HTTP binary).",
        "operationId": "ingestOtlpLogs",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "type": "object" },
              "example": {
                "resourceLogs": [
                  {
                    "resource": { "attributes": [{ "key": "service.name", "value": { "stringValue": "api" } }] },
                    "scopeLogs": [
                      {
                        "logRecords": [
                          {
                            "timeUnixNano": "1715680800000000000",
                            "severityText": "INFO",
                            "body": { "stringValue": "GET /api/users 200 42ms" }
                          }
                        ]
                      }
                    ]
                  }
                ]
              }
            },
            "application/x-protobuf": {
              "schema": { "type": "string", "format": "binary" }
            }
          }
        },
        "responses": {
          "200": { "description": "Accepted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/api/v1/syslog": {
      "post": {
        "tags": ["Ingest"],
        "summary": "Syslog over HTTP",
        "description": "Accepts RFC 5424 and RFC 3164 syslog frames as the request body, newline-delimited for batched submission. Use with rsyslog `omhttp`, syslog-ng `http()`, or any HTTP-syslog forwarder.",
        "operationId": "ingestSyslogHttp",
        "requestBody": {
          "required": true,
          "content": {
            "text/plain": {
              "schema": { "type": "string" },
              "example": "<134>1 2026-05-14T10:00:00Z web-01 nginx 1234 - - GET /api/users 200 42ms\n<131>1 2026-05-14T10:00:01Z web-01 api 1234 - - connection timeout\n"
            }
          }
        },
        "responses": {
          "200": { "description": "Accepted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/search": {
      "post": {
        "tags": ["Search"],
        "summary": "Search logs",
        "description": "Full-text and field search over the tenant's log store. Uses LogsQL query syntax — see [the query reference](https://getepok.dev/docs/query) for operators and pipes.\n\nSupports cursor-style pagination: pass the last entry's `_time` as `before` to fetch older logs.",
        "operationId": "searchLogs",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "enrich",
            "in": "query",
            "description": "Enrich error entries with first-occurrence fingerprint metadata.",
            "required": false,
            "schema": { "type": "boolean", "default": true }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SearchRequest" },
              "example": {
                "query": "level:error service:api",
                "start": "-1h",
                "end": "now",
                "limit": 100
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Matching log entries (newest first).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SearchResponse" },
                "example": {
                  "logs": [
                    {
                      "_time": "2026-05-14T10:00:01.234Z",
                      "_msg": "connection timeout after 30s on port 5432",
                      "_stream": "{service=\"api\",host=\"web-01\"}",
                      "level": "error",
                      "service": "api"
                    }
                  ],
                  "count": 1,
                  "elapsed_ms": 142,
                  "has_more": false,
                  "oldest_time": "2026-05-14T10:00:01.234Z"
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "504": { "$ref": "#/components/responses/Timeout" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/hits": {
      "get": {
        "tags": ["Search"],
        "summary": "Volume histogram",
        "description": "Log count per time bucket for a query. Drives volume charts and anomaly visualizations.",
        "operationId": "getHits",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "query",
            "in": "query",
            "description": "LogsQL filter (defaults to `*` — all logs).",
            "required": false,
            "schema": { "type": "string", "default": "*", "example": "level:error" }
          },
          { "$ref": "#/components/parameters/Start" },
          { "$ref": "#/components/parameters/End" },
          {
            "name": "step",
            "in": "query",
            "description": "Bucket size. One of `1m`, `5m`, `15m`, `1h`, `6h`, `1d`.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["1m", "5m", "15m", "1h", "6h", "1d"],
              "default": "1m"
            }
          },
          {
            "name": "fields_by",
            "in": "query",
            "description": "Group buckets by these fields (e.g. `_stream`, `service`). Max 10.",
            "required": false,
            "explode": true,
            "schema": {
              "type": "array",
              "items": { "type": "string" },
              "maxItems": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bucketed hit counts.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HitsResponse" },
                "example": {
                  "hits": [
                    { "time": "2026-05-14T09:55:00Z", "hits": 340 },
                    { "time": "2026-05-14T09:56:00Z", "hits": 318 },
                    { "time": "2026-05-14T09:57:00Z", "hits": 1102 }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "504": { "$ref": "#/components/responses/Timeout" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/facets": {
      "get": {
        "tags": ["Search"],
        "summary": "Field facets",
        "description": "Top values per field within a query window. Used to populate filter sidebars and discover what changed during an anomaly.",
        "operationId": "getFacets",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "default": "*", "example": "level:error" }
          },
          { "$ref": "#/components/parameters/Start" },
          { "$ref": "#/components/parameters/End" },
          {
            "name": "limit",
            "in": "query",
            "description": "Top-N values per field.",
            "required": false,
            "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 1000 }
          }
        ],
        "responses": {
          "200": {
            "description": "Top values per field.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FacetsResponse" },
                "example": {
                  "facets": {
                    "service": [
                      { "value": "api", "hits": 4012 },
                      { "value": "worker", "hits": 837 }
                    ],
                    "level": [
                      { "value": "info", "hits": 4621 },
                      { "value": "error", "hits": 228 }
                    ]
                  },
                  "truncated_fields": 0
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "504": { "$ref": "#/components/responses/Timeout" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/streams": {
      "get": {
        "tags": ["Search"],
        "summary": "List streams",
        "description": "Streams (unique log sources, identified by their full label set) with hit counts in the query window.",
        "operationId": "getStreams",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "default": "*" }
          },
          { "$ref": "#/components/parameters/Start" },
          { "$ref": "#/components/parameters/End" }
        ],
        "responses": {
          "200": {
            "description": "Streams with hit counts.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StreamsResponse" },
                "example": {
                  "streams": [
                    { "id": "{service=\"api\",host=\"web-01\"}", "hits": 3214 },
                    { "id": "{service=\"worker\",host=\"queue-02\"}", "hits": 891 }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/alerts": {
      "get": {
        "tags": ["Alerts"],
        "summary": "List alerts",
        "description": "Active (`firing`) and recently resolved alerts for the tenant, ordered newest first. Paginated.",
        "operationId": "listAlerts",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "state",
            "in": "query",
            "description": "Filter by lifecycle state.",
            "required": false,
            "schema": { "type": "string", "enum": ["firing", "resolved", "all"] }
          },
          {
            "name": "severity",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["info", "warning", "critical", "all"] }
          },
          {
            "name": "detector_type",
            "in": "query",
            "description": "Filter by detector that produced the alert.",
            "required": false,
            "schema": { "type": "string", "example": "log_rate" }
          },
          {
            "name": "service",
            "in": "query",
            "description": "Filter to alerts whose stream contains `service=\"X\"`.",
            "required": false,
            "schema": { "type": "string", "maxLength": 512 }
          },
          {
            "name": "since",
            "in": "query",
            "description": "Only return alerts fired after this ISO-8601 timestamp.",
            "required": false,
            "schema": { "type": "string", "format": "date-time" }
          },
          {
            "name": "until",
            "in": "query",
            "description": "Only return alerts fired before this ISO-8601 timestamp.",
            "required": false,
            "schema": { "type": "string", "format": "date-time" }
          },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Paginated alert list.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaginatedAlerts" },
                "example": {
                  "items": [
                    {
                      "id": 18342,
                      "detector_type": "log_rate",
                      "stream": "{service=\"api\",host=\"web-01\"}",
                      "severity": "critical",
                      "title": "Log rate spike on api",
                      "description": "api logs spiked to 8.4× the seasonal baseline.",
                      "state": "firing",
                      "fired_at": "2026-05-14T10:02:11Z",
                      "fire_count": 3,
                      "evidence": { "z_score": 8.4, "current": 1102, "baseline_mean": 131.2 }
                    }
                  ],
                  "total": 1,
                  "limit": 100,
                  "offset": 0,
                  "has_more": false
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/alerts/{alert_id}": {
      "get": {
        "tags": ["Alerts"],
        "summary": "Get alert",
        "operationId": "getAlert",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          { "$ref": "#/components/parameters/AlertId" }
        ],
        "responses": {
          "200": {
            "description": "Alert.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Alert" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/alerts/{alert_id}/resolve": {
      "post": {
        "tags": ["Alerts"],
        "summary": "Manually resolve an alert",
        "description": "Marks the alert as resolved. Requires an API key with the `write` scope.",
        "operationId": "resolveAlert",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          { "$ref": "#/components/parameters/AlertId" }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "note": { "type": "string", "description": "Optional resolution note." }
                }
              },
              "example": { "note": "Cleared after deploy 2026.05.14-r2." }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Alert resolved.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Alert" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },

    "/api/v1/tenants/{tenant_id}/patterns": {
      "get": {
        "tags": ["Intelligence"],
        "summary": "List log pattern clusters",
        "description": "Pattern clusters produced by the intelligence engine. Each cluster has a stable `fingerprint` that persists across log volume changes — same normalized template always returns the same fingerprint. Use this endpoint to discover new error types or surface trending patterns.",
        "operationId": "listPatterns",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["count", "first_seen", "last_seen"], "default": "count" }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
          },
          {
            "name": "since",
            "in": "query",
            "description": "Lookback window: `-1h`, `-24h`, `-7d`, etc.",
            "required": false,
            "schema": { "type": "string", "default": "-24h", "pattern": "^-\\d+[hd]$" }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Substring filter on the normalized template.",
            "required": false,
            "schema": { "type": "string" }
          },
          {
            "name": "service",
            "in": "query",
            "description": "Filter to patterns whose streams contain `service=\"X\"`.",
            "required": false,
            "schema": { "type": "string", "maxLength": 512 }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "default": 50, "minimum": 1, "maximum": 2000 }
          },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Pattern list.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PatternsResponse" },
                "example": {
                  "patterns": [
                    {
                      "fingerprint": "a1b2c3d4e5f6...",
                      "normalized_message": "connection timeout after <N>s on port <N>",
                      "example_raw_message": "connection timeout after 30s on port 5432",
                      "first_seen": "2026-05-14T10:02:14Z",
                      "last_seen": "2026-05-14T10:14:55Z",
                      "count": 1247,
                      "streams": ["{service=\"api\",host=\"web-01\"}"]
                    }
                  ],
                  "has_more": false
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },

    "/ws/livetail/{tenant_id}": {
      "get": {
        "tags": ["Live"],
        "summary": "WebSocket live-tail",
        "description": "Upgrade this endpoint to a WebSocket to stream matching log entries in real time. After the connection is open, send a JSON filter message `{\"query\": \"level:error\"}` to start the stream.\n\n**Auth note:** WebSocket auth uses the `Sec-WebSocket-Protocol` header with value `epok.bearer.<api_key>` (preferred) or a browser session cookie. Sending the API key as a query-string is supported for legacy clients but discouraged — query strings appear in server access logs.",
        "operationId": "wsLivetail",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" }
        ],
        "responses": {
          "101": { "description": "Protocol switched to WebSocket." },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },

    "/ws/alerts/{tenant_id}": {
      "get": {
        "tags": ["Live"],
        "summary": "WebSocket alert stream",
        "description": "Upgrade to a WebSocket to receive real-time alert fires and resolutions. Same auth options as the live-tail stream.",
        "operationId": "wsAlerts",
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" }
        ],
        "responses": {
          "101": { "description": "Protocol switched to WebSocket." },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    }
  },

  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "epk_<key>",
        "description": "Tenant-scoped API key. Required scope: `read` (default) or `write` (for state-changing operations)."
      },
      "BasicAuth": {
        "type": "http",
        "scheme": "basic",
        "description": "Username = API key, password = empty. Used by Loki-native shippers."
      },
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Legacy header form. Prefer `Authorization: Bearer`."
      }
    },

    "parameters": {
      "TenantId": {
        "name": "tenant_id",
        "in": "path",
        "required": true,
        "description": "Numeric tenant identifier. Must match the tenant the API key belongs to.",
        "schema": { "type": "integer", "minimum": 1, "example": 42 }
      },
      "AlertId": {
        "name": "alert_id",
        "in": "path",
        "required": true,
        "schema": { "type": "integer", "minimum": 1, "example": 18342 }
      },
      "Start": {
        "name": "start",
        "in": "query",
        "description": "Window start. Relative duration (`-1h`, `-15m`, `-7d`) or ISO-8601 timestamp.",
        "required": false,
        "schema": { "type": "string", "default": "-1h", "example": "-1h" }
      },
      "End": {
        "name": "end",
        "in": "query",
        "description": "Window end. Relative duration, ISO-8601 timestamp, or the literal `now`.",
        "required": false,
        "schema": { "type": "string", "default": "now", "example": "now" }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Max results per page.",
        "required": false,
        "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 2000 }
      },
      "Offset": {
        "name": "offset",
        "in": "query",
        "description": "Zero-based pagination offset.",
        "required": false,
        "schema": { "type": "integer", "default": 0, "minimum": 0 }
      }
    },

    "responses": {
      "BadRequest": {
        "description": "Request was malformed (invalid syntax, parameter out of range, etc.).",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Query cannot be empty", "request_id": "3f0e352efc32" }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Unauthorized", "request_id": "3f0e352efc32" }
          }
        }
      },
      "Forbidden": {
        "description": "API key lacks the required scope (e.g. `write`), or this is a read-only public demo tenant.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "API key missing required 'write' scope", "request_id": "3f0e352efc32" }
          }
        }
      },
      "NotFound": {
        "description": "Resource does not exist or is not visible to this tenant.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Not Found", "request_id": "3f0e352efc32" }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "Ingest body exceeded the per-request size cap.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Payload too large", "request_id": "3f0e352efc32" }
          }
        }
      },
      "RateLimited": {
        "description": "Per-tenant or per-key rate limit exceeded. Inspect the `Retry-After` response header (seconds) before retrying.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": { "type": "integer", "example": 30 }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Rate limit exceeded", "request_id": "3f0e352efc32" }
          }
        }
      },
      "Timeout": {
        "description": "Log backend query timed out. Try narrowing the time range or simplifying the query.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": { "detail": "Log query timed out", "request_id": "3f0e352efc32" }
          }
        }
      }
    },

    "schemas": {
      "HealthResponse": {
        "type": "object",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "enum": ["ok"], "example": "ok" }
        }
      },

      "ErrorResponse": {
        "type": "object",
        "required": ["detail"],
        "properties": {
          "detail": {
            "type": "string",
            "description": "Human-readable error message.",
            "example": "Query cannot be empty"
          },
          "request_id": {
            "type": "string",
            "description": "Echoes the `X-Request-Id` response header. Include this when contacting support.",
            "example": "3f0e352efc32"
          }
        }
      },

      "IngestAck": {
        "type": "object",
        "properties": {
          "took": { "type": "integer", "description": "Processing time in milliseconds.", "example": 4 },
          "errors": { "type": "boolean", "description": "True if any entry was rejected.", "example": false },
          "items": {
            "type": "array",
            "description": "Per-entry status (empty when `errors=false`).",
            "items": { "type": "object" }
          }
        }
      },

      "LokiPushBody": {
        "type": "object",
        "required": ["streams"],
        "properties": {
          "streams": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["stream", "values"],
              "properties": {
                "stream": {
                  "type": "object",
                  "description": "Label set for this stream. Free-form keys/values.",
                  "additionalProperties": { "type": "string" },
                  "example": { "service": "api", "host": "web-01" }
                },
                "values": {
                  "type": "array",
                  "description": "Array of `[unix_ns_timestamp_string, log_line]` pairs.",
                  "items": {
                    "type": "array",
                    "items": { "type": "string" },
                    "minItems": 2,
                    "maxItems": 2
                  }
                }
              }
            }
          }
        }
      },

      "SearchRequest": {
        "type": "object",
        "required": ["query"],
        "properties": {
          "query": {
            "type": "string",
            "description": "LogsQL query. Max 10,000 characters.",
            "maxLength": 10000,
            "example": "level:error service:api"
          },
          "start": {
            "type": "string",
            "description": "Window start. Relative (`-1h`) or ISO-8601.",
            "default": "-1h",
            "example": "-1h"
          },
          "end": {
            "type": "string",
            "description": "Window end.",
            "default": "now",
            "example": "now"
          },
          "limit": {
            "type": "integer",
            "description": "Max log entries to return.",
            "default": 1000,
            "minimum": 1,
            "maximum": 10000
          },
          "before": {
            "type": "string",
            "description": "Cursor: when paginating, pass the previous response's `oldest_time` to fetch older entries.",
            "format": "date-time",
            "nullable": true
          }
        }
      },

      "SearchResponse": {
        "type": "object",
        "required": ["logs", "count"],
        "properties": {
          "logs": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/LogEntry" }
          },
          "count": { "type": "integer", "description": "Number of entries in this response.", "example": 1 },
          "elapsed_ms": { "type": "integer", "description": "Server-side query time.", "example": 142 },
          "has_more": { "type": "boolean", "description": "True when more entries exist before `oldest_time`. Use `before` to paginate.", "example": false },
          "oldest_time": { "type": "string", "format": "date-time", "nullable": true, "example": "2026-05-14T10:00:01.234Z" }
        }
      },

      "LogEntry": {
        "type": "object",
        "description": "Single log entry. Schemaless — `_time`, `_msg`, `_stream` are always present; other fields depend on what the producer sent.",
        "required": ["_time", "_msg"],
        "properties": {
          "_time": { "type": "string", "format": "date-time", "example": "2026-05-14T10:00:01.234Z" },
          "_msg": { "type": "string", "example": "GET /api/users 200 42ms" },
          "_stream": { "type": "string", "example": "{service=\"api\",host=\"web-01\"}" },
          "level": { "type": "string", "example": "info" },
          "service": { "type": "string", "example": "api" }
        },
        "additionalProperties": true
      },

      "HitsResponse": {
        "type": "object",
        "required": ["hits"],
        "properties": {
          "hits": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/HitsBucket" }
          }
        }
      },

      "HitsBucket": {
        "type": "object",
        "required": ["time", "hits"],
        "properties": {
          "time": { "type": "string", "format": "date-time", "example": "2026-05-14T09:55:00Z" },
          "hits": { "type": "integer", "minimum": 0, "example": 340 },
          "stream": { "type": "string", "nullable": true, "description": "Present when grouped via `fields_by=_stream`." }
        },
        "additionalProperties": true
      },

      "FacetsResponse": {
        "type": "object",
        "required": ["facets"],
        "properties": {
          "facets": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": { "$ref": "#/components/schemas/FacetValue" }
            }
          },
          "truncated_fields": {
            "type": "integer",
            "description": "Number of fields omitted from the response because the per-request cap was reached.",
            "example": 0
          }
        }
      },

      "FacetValue": {
        "type": "object",
        "required": ["value", "hits"],
        "properties": {
          "value": { "type": "string", "example": "api" },
          "hits": { "type": "integer", "example": 4012 }
        }
      },

      "StreamsResponse": {
        "type": "object",
        "required": ["streams"],
        "properties": {
          "streams": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/StreamInfo" }
          }
        }
      },

      "StreamInfo": {
        "type": "object",
        "required": ["id", "hits"],
        "properties": {
          "id": { "type": "string", "description": "Stream label set in `{k=\"v\",…}` form.", "example": "{service=\"api\",host=\"web-01\"}" },
          "hits": { "type": "integer", "example": 3214 }
        }
      },

      "Alert": {
        "type": "object",
        "required": ["id", "detector_type", "stream", "severity", "title", "description", "state", "fired_at", "fire_count", "evidence"],
        "properties": {
          "id": { "type": "integer", "example": 18342 },
          "detector_type": { "type": "string", "description": "Which detector produced this alert.", "example": "log_rate" },
          "stream": { "type": "string", "example": "{service=\"api\",host=\"web-01\"}" },
          "severity": { "type": "string", "enum": ["info", "warning", "critical"], "example": "critical" },
          "title": { "type": "string", "example": "Log rate spike on api" },
          "description": { "type": "string", "example": "api logs spiked to 8.4× the seasonal baseline." },
          "evidence": {
            "type": "object",
            "description": "Detector-specific evidence (z-score, sample messages, etc.).",
            "additionalProperties": true,
            "example": { "z_score": 8.4, "current": 1102, "baseline_mean": 131.2 }
          },
          "state": { "type": "string", "enum": ["firing", "resolved"], "example": "firing" },
          "fired_at": { "type": "string", "format": "date-time", "example": "2026-05-14T10:02:11Z" },
          "resolved_at": { "type": "string", "format": "date-time", "nullable": true },
          "fire_count": { "type": "integer", "description": "How many times this dedup key has re-fired.", "example": 3 },
          "last_updated_at": { "type": "string", "format": "date-time", "nullable": true },
          "resolution_note": { "type": "string", "nullable": true },
          "incident_id": { "type": "integer", "nullable": true, "description": "Incident this alert belongs to, if grouped." }
        }
      },

      "PaginatedAlerts": {
        "type": "object",
        "required": ["items", "total", "limit", "offset", "has_more"],
        "properties": {
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/Alert" } },
          "total": { "type": "integer", "minimum": 0, "example": 1 },
          "limit": { "type": "integer", "example": 100 },
          "offset": { "type": "integer", "example": 0 },
          "has_more": { "type": "boolean", "example": false }
        }
      },

      "PatternsResponse": {
        "type": "object",
        "required": ["patterns"],
        "properties": {
          "patterns": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Pattern" }
          },
          "has_more": { "type": "boolean", "example": false }
        }
      },

      "Pattern": {
        "type": "object",
        "required": ["fingerprint", "normalized_message", "count", "first_seen", "last_seen"],
        "properties": {
          "fingerprint": { "type": "string", "description": "Stable SHA-256 hex hash of the normalized template.", "example": "a1b2c3d4e5f6..." },
          "normalized_message": { "type": "string", "description": "Template with numbers, IPs, and IDs replaced with placeholders.", "example": "connection timeout after <N>s on port <N>" },
          "example_raw_message": { "type": "string", "description": "One concrete message that matches this pattern.", "example": "connection timeout after 30s on port 5432" },
          "first_seen": { "type": "string", "format": "date-time", "example": "2026-05-14T10:02:14Z" },
          "last_seen": { "type": "string", "format": "date-time", "example": "2026-05-14T10:14:55Z" },
          "count": { "type": "integer", "minimum": 0, "example": 1247 },
          "streams": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Stream selectors this pattern has appeared in.",
            "example": ["{service=\"api\",host=\"web-01\"}"]
          }
        }
      }
    }
  }
}
