# Generated by cmd/gen-openapi-soar. DO NOT EDIT.
#
# This is the SOAR profile: the subset of the obserae REST API an
# orchestration platform actually calls, derived from openapi.yaml by keeping
# every operation marked "x-soar: true" and everything those operations
# reference.
#
# Edit openapi.yaml and run "make docs". TestSOARProfileIsUpToDate fails if
# this file and that one disagree.
openapi: 3.1.0
info:
  title: obserae REST API
  version: "1.0.0"
  license:
    name: See EULA.txt
  description: |
    Machine-readable description of the obserae HTTP API. Every path under
    `/api/…` returns JSON; the human-friendly guide with `curl` examples is in
    [rest-api.md](rest-api.md).

    ## Authentication
    All endpoints require an API token sent as a Bearer credential
    (`Authorization: Bearer obs_…`), except the two public probes `GET /healthz`
    and `GET /api/protocols`. Tokens are minted in the GUI (Identity & Access →
    Users → + New token) or via `obserae-cli user token-add`, and inherit their
    owner's permissions. Bearer clients are exempt from CSRF and MFA.

    ## Permissions
    Each operation states the permission its caller must hold in `x-permission`
    (and in the description). A caller missing it gets `403`. `x-permission: ""`
    means any authenticated token is enough.

    ## WebSockets
    Three real-time streams are **not** modelled here (OpenAPI does not describe
    WebSocket handshakes): `GET /ws/health` (any authenticated),
    `GET /ws/alerts` (`alerts:read`) and `GET /ws/carto` (`cartography:read`).
    See rest-api.md.

    obserae is in **beta** — endpoints and payloads may change between releases.

    ## This is the SOAR profile

    This document is derived from the full obserae specification and holds only the operations an orchestration platform calls, each with a typed response and an example. It is the file to import into a SOAR. The complete API — every one of its 200-odd operations — is in `openapi.yaml`.
servers:
  - url: "{scheme}://{host}:{port}"
    description: >-
      This instance. The defaults describe an untouched local bind (`web.bind`); a deployment behind TLS overrides them, which is what lets a generated client or a SOAR connector target a real host without the spec being edited by hand.
    variables:
      scheme:
        default: https
        enum: [http, https]
      host:
        default: 127.0.0.1
      port:
        default: "8080"
security:
  - bearerAuth: []
tags:
  - name: SOAR
    description: >-
      The orchestration profile: the small set of operations a playbook actually calls. Every one of them has a typed response, an example, and the `x-soar: true` extension. They keep their domain tag too — the SOAR tag selects them, it does not move them.


      A connector exposing all 220 operations is unusable in practice, so the profile is published on its own as `openapi-soar.yaml`, derived from this document by filtering on `x-soar`. That derived file is the one to import into an orchestrator.
  - name: Health
    description: Liveness probe and the machine-facing status snapshot.
  - name: NFQL
    description: Execute NFQL queries and manage saved queries.
  - name: Cartography
    description: Read and edit the network map (hosts, networks, groups, services).
  - name: Rules
    description: Flow-matrix rules — model and inspect allowed connectivity.
  - name: Alerts
    description: Read and acknowledge detection alerts.
  - name: Sessions
    description: Read session aggregations.
  - name: Enrichment
    description: Enrichment sources and flow exporters.
paths:
  /api/status:
    get:
      tags: [Health, SOAR]
      summary: Daemon status snapshot
      operationId: getStatus
      x-soar: true
      description: |
        Full machine-facing JSON snapshot — identical to `obserae-cli status`.
        Requires `monitoring:read`.
      x-permission: monitoring:read
      responses:
        "200":
          description: Status snapshot
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Status"}
              example:
                # Abridged on purpose — the full snapshot carries every block
                # of the schema above. These are the fields a playbook reads:
                # is the instance the one I think it is, is it alive, and how
                # much is currently open.
                version: "0.33.0"
                started_at: "2026-06-04T08:12:00Z"
                uptime_seconds: 13920
                timestamp: "2026-06-04T12:04:00Z"
                data_version: 36
                data_version_target: 36
                flows: {total: 481203344, per_sec: 1820.4}
                alerts:
                  total: 41
                  by_status: {new: 12, ack: 6, closed: 23}
                  by_severity: {high: 9, medium: 20, low: 12}
                  last_1h: 3
                  last_24h: 27
                live_metrics_available: true
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  # ------------------------------------------------------------------ NFQL
  /api/query:
    post:
      tags: [NFQL, SOAR]
      summary: Execute an NFQL query
      operationId: executeQuery
      x-soar: true
      description: |
        Compile and execute an NFQL pipeline on the read-only pool (row-capped,
        timed). Requires `nfql:execute`. A compile error is returned as **200**
        with a populated `error` field, not an HTTP error.

        This is the escape hatch of the SOAR profile: the typed operations
        cover the common questions, and this one covers the rest. Pass an
        alert's `query.nfql` and `query.as_of` straight through to see exactly
        what the rule saw, at the instant it saw it.
      x-permission: nfql:execute
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/ExecuteRequest"}
      responses:
        "200":
          description: Result rows, or a compile error in the `error` field
          content:
            application/json:
              schema: {$ref: "#/components/schemas/ExecuteResponse"}
              example:
                columns:
                  - {name: client_ip, type: VARCHAR}
                  - {name: server_ip, type: VARCHAR}
                  - {name: server_port, type: INTEGER}
                rows:
                  - ["10.0.0.50", "10.20.4.11", 5432]
                  - ["10.0.0.51", "10.20.4.11", 5432]
                compile_ms: 2
                exec_ms: 41
                row_count: 2
                truncated: false
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/query/resolve-ip:
    get:
      tags: [NFQL, SOAR]
      summary: Resolve an IP to its cartography entity
      operationId: resolveIp
      description: |
        Turn an address into the closest catalogued context. Requires
        `sessions:read`.

        Three tiers, reported in `kind`: `host` — exactly one host declares the
        address on an interface; `network` — it falls inside a declared CIDR
        but no host claims it, the usual signature of shadow IT in a known
        segment; `unknown` — nothing matches.

        Always 200 for a well-formed address. An unknown address is an answer,
        not an error.
      x-permission: sessions:read
      x-soar: true
      parameters:
        - name: ip
          in: query
          required: true
          description: An IPv4 or IPv6 address. A malformed one is a 400.
          schema: {type: string, example: "10.0.0.50"}
      responses:
        "200":
          description: The catalogued context of the address
          content:
            application/json:
              schema: {$ref: "#/components/schemas/ResolvedIp"}
              example:
                ip: "10.0.0.50"
                kind: host
                host: ci-runner-prod
                iface: eth0
                network: work
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/carto/search:
    get:
      tags: [Cartography, SOAR]
      summary: Search hosts, networks and groups
      operationId: searchCartography
      x-permission: cartography:read
      x-soar: true
      description: |
        Find catalogued entities by name. Requires `cartography:read`.

        Results are ranked: exact name first, then prefix, then substring;
        within a tier, hosts before groups before networks. An empty `q`
        returns the first entities of the map rather than an error, which is
        what makes this usable as a picker.

        `label` is pre-formatted as `<kind>:<name>` — the exact spelling the
        rule endpoints and `findCoveringRule` accept, so a result can be fed
        straight into the next call without reassembly.
      parameters:
        - name: q
          in: query
          description: The name fragment to search for. Empty lists the map.
          schema: {type: string, example: db}
      responses:
        "200":
          description: The matching entities, best first
          content:
            application/json:
              schema: {$ref: "#/components/schemas/CartoSearchResponse"}
              example:
                query: db
                matches:
                  - kind: host
                    name: db-01
                    label: "host:db-01"
                    context: "10.20.4.11 · in group:databases"
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/carto/hosts/{name}:
    parameters: [{$ref: "#/components/parameters/Name"}]
    get:
      tags: [Cartography, SOAR]
      summary: Read one catalogued asset
      operationId: getHost
      x-permission: cartography:read
      x-soar: true
      description: |
        Read a host's declared interfaces, services and group membership.
        Requires `cartography:read`.

        `groups` is resolved server-side. Membership is declared on the group,
        not on the host, and it is usually the field that decides an
        escalation — "is this production?" — so answering it here saves a
        second call and a client-side scan of the whole map.

        Every list is present and possibly empty: a catalogued host with
        nothing declared on it is a meaningful answer, distinct from a 404.
      responses:
        "200":
          description: The asset as the map declares it
          content:
            application/json:
              schema: {$ref: "#/components/schemas/HostDetail"}
              example:
                name: ci-runner-prod
                role: workstation
                interfaces:
                  - {name: eth0, network: work, ip: "10.0.0.50"}
                services:
                  - {name: ssh, protocol: tcp, port: 22}
                groups: [build]
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/rules/find-covering:
    post:
      tags: [Rules, SOAR]
      summary: Find rules covering a flow
      operationId: findCoveringRule
      x-permission: rules:read
      x-soar: true
      description: |
        Answer whether the flow matrix already allows a given flow, and name
        the rules that do. Requires `rules:read` — the question changes
        nothing; it is a POST only because a src/dst/port/protocol tuple does
        not fit in a path.

        At least one of `src` or `dst` is required. Both accept the same
        alphabet as a rule's own endpoints: `host:NAME`, `group:NAME`,
        `network:NAME`, or a bare IP address. An omitted `port` or `protocol`
        means "any".

        An empty `matches` array is the answer "nothing covers this", not an
        error.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/FindCoveringRequest"}
            example: {src: "10.0.0.50", dst: "host:db-01", port: 5432, protocol: tcp}
      responses:
        "200":
          description: The rules covering the flow, if any
          content:
            application/json:
              schema: {$ref: "#/components/schemas/FindCoveringResponse"}
              example: {matches: ["work-to-db"]}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/indicators:
    get:
      tags: [Alerts, SOAR]
      summary: Pull the observed-indicator feed
      operationId: exportIndicators
      x-soar: true
      x-permission: indicators:read
      description: |
        The public addresses obserae's own detection rules fired on, as a list a
        firewall, an RPZ or a blocklist can ingest.

        **obserae publishes this list and does not block.** It is out-of-band
        and on the path of nothing; there is no API here that acts on a device.
        What happens to these addresses is decided entirely by whatever pulls
        the feed. That sentence is in the feed itself, not only here.

        Only publicly routable addresses are published: an RFC1918 address in a
        list a firewall acts on would have someone block their own network. An
        address is one indicator however many alerts saw it, and it carries the
        **highest** severity any of them gave it.

        The permission is a dedicated `indicators:read`, so the device pulling
        this can hold that and nothing else. It is in no built-in group but
        `admin`.

        **Poll it conditionally.** `ETag` and `Last-Modified` are always
        present, and an unchanged `If-None-Match` is answered `304`. A consumer
        polling every five minutes without that is a full recomputation twelve
        times an hour, forever. The `ETag` covers the format too, so switching
        representation is never answered `304`.
      parameters:
        - name: type
          in: query
          description: The indicator type. Only `ip` exists today.
          schema: {type: string, enum: [ip], default: ip}
        - name: since
          in: query
          description: >-
            How far back to look. It is what bounds the scan, so an unsupported value is refused rather than widened.
          schema: {type: string, enum: ["1h", "24h", "7d", "30d"], default: "24h"}
        - name: min_severity
          in: query
          description: Keep only indicators at this severity or above.
          schema: {type: string, enum: [info, low, medium, high, critical]}
        - name: format
          in: query
          description: >-
            `plain` — one address per line, `#` comments; the format a firewall or an RPZ ingests directly. `json` — an envelope with the provenance. `csv` — a header and one row per indicator. `misp` — a MISP feed manifest.
          schema: {type: string, enum: [plain, json, csv, misp], default: plain}
        - name: limit
          in: query
          description: Cap the list. Defaults to 5000, which is also the ceiling.
          schema: {type: integer, minimum: 1, maximum: 5000, default: 5000}
        - name: If-None-Match
          in: header
          description: >-
            The `ETag` of the copy you already hold. An unchanged feed is answered `304 Not Modified` with no body.
          schema: {type: string}
      responses:
        "200":
          description: The feed, in the requested format
          headers:
            ETag:
              description: >-
                Entity tag of this representation. It covers the format as well as the content.
              schema: {type: string}
            Last-Modified:
              description: >-
                The newest sighting in the feed — when its CONTENT last changed, not when it was asked for.
              schema: {type: string}
            Cache-Control:
              description: How often the answer can change at all.
              schema: {type: string}
          content:
            text/plain:
              schema: {type: string}
              example: |
                # obserae observed-indicator feed
                # instance: obserae-paris-dc1
                # generated: 2026-06-04T12:00:00Z
                # window: 24h
                # count: 2
                # obserae publishes this list and does not block: it is out-of-band
                # and on the path of nothing. What happens to these addresses is
                # decided by whatever consumes this feed.
                198.51.100.7
                203.0.113.9
            application/json:
              schema: {$ref: "#/components/schemas/IndicatorFeed"}
              example:
                generated_at: "2026-06-04T12:00:00Z"
                window: 24h
                instance: obserae-paris-dc1
                count: 1
                truncated: false
                notice: obserae publishes this list and does not block. Enforcement belongs to whatever consumes the feed.
                indicators:
                  - value: "198.51.100.7"
                    type: ip
                    severity: critical
                    alerts: 1
                    first_seen: "2026-06-04T10:00:00Z"
                    last_seen: "2026-06-04T10:00:00Z"
                    rules: [c2-beacon]
            text/csv:
              schema: {type: string}
              example: |
                value,type,severity,alerts,first_seen,last_seen,rules
                198.51.100.7,ip,critical,1,2026-06-04T10:00:00Z,2026-06-04T10:00:00Z,c2-beacon
        "304":
          description: The feed has not changed since the supplied `ETag`
          headers:
            ETag:
              description: Repeated, as RFC 9110 requires.
              schema: {type: string}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/context/host:
    get:
      tags: [Sessions, SOAR]
      summary: One host's recent network behaviour
      operationId: getHostContext
      x-soar: true
      x-permission: sessions:read
      description: |
        Who this host talked to, who talked to it, what it served, how much it
        moved, and which of its peers are new — in one call, without writing a
        query.

        Every peer carries the flow matrix's verdict on that exact
        conversation (`policy_covered`), which is what makes the answer a
        segmentation report rather than a traffic dump. It is the same verdict
        `checkPolicy` returns for the same pair.

        `window` is a closed vocabulary — `1h`, `24h` (the default) or `7d` —
        because it is what bounds the scan. `as_of` MOVES that window without
        widening it, so a playbook can place itself at the instant an alert
        fired: pass the alert's `fired_at`.

        An address obserae never saw is an empty answer, not an error. Every
        list is present and empty rather than null, so a client can iterate
        unconditionally.
      parameters:
        - name: ip
          in: query
          required: true
          description: The host to describe.
          schema: {type: string, example: "10.0.0.50"}
        - name: window
          in: query
          description: How far back to look. Anything else is rejected, never rounded.
          schema: {type: string, enum: ["1h", "24h", "7d"], default: "24h"}
        - name: as_of
          in: query
          description: >-
            RFC3339 anchor for the end of the window. Defaults to now. Pass an alert's `fired_at` to see what the host was doing when it fired.
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: The host's context over the window
          content:
            application/json:
              schema: {$ref: "#/components/schemas/HostContextResponse"}
              example:
                entity:
                  ip: "10.0.0.50"
                  scope: internal
                  carto: {resolved: true, host: ci-runner-prod, network: build}
                window: 24h
                as_of: "2026-06-04T12:00:00Z"
                services_exposed:
                  - {port: 22, protocol: tcp, name: ssh, clients: 1, sessions: 1, bytes: 10000, last_seen: "2026-06-04T09:15:00Z"}
                talks_to:
                  - {peer: "10.20.4.11", port: 5432, protocol: tcp, sessions: 2, bytes: 8000, first_seen: "2026-06-04T11:30:00Z", last_seen: "2026-06-04T11:50:00Z", policy_covered: true}
                  - {peer: "198.51.100.7", port: 4444, protocol: tcp, sessions: 1, bytes: 5800, first_seen: "2026-06-04T10:00:00Z", last_seen: "2026-06-04T10:05:00Z", policy_covered: false}
                talked_to_by:
                  - {peer: "10.0.0.51", port: 22, protocol: tcp, sessions: 1, bytes: 10000, first_seen: "2026-06-04T09:00:00Z", last_seen: "2026-06-04T09:15:00Z", policy_covered: true}
                volume: {in_bytes: 7800, out_bytes: 13000}
                new_peers_in_window: ["198.51.100.7"]
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/context/peers:
    post:
      tags: [Sessions, SOAR]
      summary: With whom did this address talk?
      operationId: getPeers
      x-soar: true
      x-permission: sessions:read
      description: |
        The same conversation question `getHostContext` answers with defaults,
        parameterised: one direction or both, inside or outside, how many, and
        anchored where you choose.

        It is a POST because six parameters — one of them a timestamp — do not
        belong in a query string a playbook has to assemble by hand.

        Every vocabulary is closed. An unrecognised `window`, `direction` or
        `scope` is refused with `400` rather than replaced by the widest answer,
        which a caller would read as a result for the filter it asked for. A
        `limit` above the ceiling of 1000 is clamped, and `truncated` says the
        list was cut.

        Pass an alert's `fired_at` as `as_of` to see the conversations that alert
        was about, rather than the ones happening now.
      requestBody:
        required: true
        description: The address to describe, and how to narrow the answer.
        content:
          application/json:
            schema: {$ref: "#/components/schemas/PeersRequest"}
            example:
              ip: "10.0.0.50"
              window: 24h
              direction: both
              scope: external
              limit: 100
      responses:
        "200":
          description: The conversation partners, largest first
          content:
            application/json:
              schema: {$ref: "#/components/schemas/PeersResponse"}
              example:
                window: 24h
                as_of: "2026-06-04T12:00:00Z"
                peers:
                  - {peer: "198.51.100.7", port: 4444, protocol: tcp, direction: out, sessions: 1, bytes: 5800, first_seen: "2026-06-04T10:00:00Z", last_seen: "2026-06-04T10:05:00Z", policy_covered: false}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/policy/check:
    post:
      tags: [Rules, SOAR]
      summary: Check one flow against the matrix
      operationId: checkPolicy
      x-soar: true
      x-permission: rules:read
      description: |
        Is this conversation already allowed by the flow matrix? The same
        question as `findCoveringRule`, answered in the shape a ticket needs: a
        boolean instead of a list to interpret, the covering rule named, and
        both endpoints resolved to their assets.

        Both surfaces run the same resolver and the same statement, and a shared
        parity fixture asserts they return the same verdict — including on the
        addresses either side of a NAT gateway.

        Unlike `findCoveringRule`, **both `src` and `dst` are required**: a
        playbook asking whether a flow is allowed always holds both ends, and a
        verdict about half a pair would answer a question nobody asked. A
        reference that resolves to nothing is answered `covered: false` with a
        `note` — never silently allowed.
      requestBody:
        required: true
        description: The conversation to test against the flow matrix.
        content:
          application/json:
            schema: {$ref: "#/components/schemas/PolicyCheckRequest"}
            example: {src: "10.0.0.50", dst: "10.20.4.11", port: 5432, protocol: tcp}
      responses:
        "200":
          description: The verdict, the covering rule, and the two endpoints
          content:
            application/json:
              schema: {$ref: "#/components/schemas/PolicyCheckResponse"}
              example:
                covered: false
                matched_rule: null
                nearest_rules:
                  - {name: build-to-db, why_not: port not in rule}
                src_entity:
                  ip: "10.0.0.50"
                  scope: internal
                  carto: {resolved: true, host: ci-runner-prod, network: build, group: build}
                dst_entity:
                  ip: "10.20.4.11"
                  scope: internal
                  carto: {resolved: true, host: db-01, network: db}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  # ---------------------------------------------------------------- Alerts
  /api/alerts:
    get:
      tags: [Alerts, SOAR]
      summary: List detection alerts
      operationId: listAlerts
      x-permission: alerts:read
      x-soar: true
      description: |
        List detection alerts, most recent first. Requires `alerts:read`.

        Filters are AND-ed. Prefer `rule_id` over `rule_name` for anything
        durable: a rule can be renamed, and a correlation built on its name
        breaks silently the day it is.

        The response is an envelope, not a bare array. `total` counts every
        alert the filter matched, independent of the window returned, so a
        client that pages knows when to stop.
      parameters:
        - name: severity
          in: query
          description: Exact severity match.
          schema: {$ref: "#/components/schemas/AlertSeverity"}
        - name: status
          in: query
          description: Exact lifecycle status match.
          schema: {$ref: "#/components/schemas/AlertStatus"}
        - name: rule_id
          in: query
          description: >-
            The immutable identifier of the rule that fired. Stable across renames — the filter to build an integration on.
          schema: {type: string, format: uuid}
        - name: rule_name
          in: query
          description: >-
            Exact rule name. Editable, therefore not a stable correlation key. Also accepted under its original spelling `rule`.
          schema: {type: string}
        - name: tag
          in: query
          description: >-
            Alerts whose rule currently carries this tag. The tag lives on the rule, so retagging changes what this returns, including for alerts that already fired.
          schema: {type: string}
        - name: since
          in: query
          description: >-
            Inclusive lower bound on `fired_at`. Either an RFC3339 instant or a relative age — `30m`, `24h`, `7d`.
          schema: {type: string, example: "24h"}
        - name: until
          in: query
          description: Inclusive upper bound on `fired_at`, same grammar as `since`.
          schema: {type: string, example: "1h"}
        - name: limit
          in: query
          description: >-
            Page size. Values above the maximum are clamped, not rejected; the response echoes the limit actually applied.
          schema: {type: integer, minimum: 1, default: 100, maximum: 10000}
        - name: offset
          in: query
          description: Rows to skip in the same ordering.
          schema: {type: integer, minimum: 0, default: 0}
      responses:
        "200":
          description: One page of alerts, with the total behind it
          content:
            application/json:
              schema: {$ref: "#/components/schemas/AlertList"}
              example:
                items:
                  - id: "0b6c1f2e-9a3d-4c1b-8f77-2c9d5e1a4b30"
                    rule_id: "5f1d8c44-1a2b-4e3f-9a01-77c2b6e4d900"
                    rule_name: postgres-present
                    fired_at: "2026-06-04T12:00:00Z"
                    severity: high
                    matched_count: 7
                    key_json: '{"client_ip":"10.0.0.50"}'
                    status: new
                total: 41
                limit: 1
                offset: 0
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/alerts/{id}/status:
    parameters: [{$ref: "#/components/parameters/Id"}]
    post:
      tags: [Alerts, SOAR]
      summary: Change an alert's status
      operationId: setAlertStatus
      x-permission: alerts:ack
      x-soar: true
      description: |
        Move an alert along its lifecycle. Requires `alerts:ack`.

        `ack` takes ownership — this is what a playbook sends when it has
        opened a ticket. `closed` states the condition is handled. `new`
        reopens. Any other value is a 400.

        Acknowledging also publishes an `alert.acknowledged` event to the
        configured outputs, so a SOAR that opened the incident sees it taken.

        Succeeds with **204 and no body**: there is nothing to decode.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/AlertStatusRequest"}
            example: {status: ack}
      responses:
        "204": {$ref: "#/components/responses/NoContent"}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/alerts/{id}:
    parameters: [{$ref: "#/components/parameters/Id"}]
    get:
      tags: [Alerts, SOAR]
      summary: Read one alert, with its evidence
      operationId: getAlert
      x-permission: alerts:read
      x-soar: true
      description: |
        Read a single alert by id. Requires `alerts:read`.

        An alert reaching an orchestrator often carries its id and nothing
        else — a webhook body, a ticket field — so this is the first call a
        resuming playbook makes.

        Unlike a list entry it carries the evidence: `columns` names the fields
        of each sampled row, `rows` holds them positionally, and `records` is
        the same rows projected onto those names. **Read `records`.** Addressing
        a field by name is the only way a playbook does not break the day
        someone edits the rule's `KEEP`.
      responses:
        "200":
          description: The alert and the rows its rule matched
          content:
            application/json:
              schema: {$ref: "#/components/schemas/AlertDetail"}
              example:
                id: "0b6c1f2e-9a3d-4c1b-8f77-2c9d5e1a4b30"
                rule_id: "5f1d8c44-1a2b-4e3f-9a01-77c2b6e4d900"
                rule_name: postgres-present
                fired_at: "2026-06-04T12:00:00Z"
                severity: high
                matched_count: 7
                key_json: '{"client_ip":"10.0.0.50"}'
                status: new
                columns:
                  - {name: client_ip, type: VARCHAR}
                  - {name: server_port, type: INTEGER}
                rows: [["10.0.0.50", 5432]]
                records: [{client_ip: "10.0.0.50", server_port: 5432}]
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  # ------------------------------------------------------------ Enrichment
  /api/enrichment/lookup:
    get:
      tags: [Enrichment, SOAR]
      summary: Look addresses up
      operationId: lookupEnrichment
      x-soar: true
      x-permission: sessions:read
      description: |
        Everything the instance knows about one address, or about a batch of
        them: which asset owns it, which enrichment feeds list it, and when
        each of those feeds last refreshed.

        This is the first call a playbook makes, so it requires `sessions:read`
        — a read — and **not** the `sources:manage` that governs the rest of
        `/api/enrichment`. A token that may ask questions must not also be able
        to reconfigure the feeds.

        Repeat `ip` to look up to 50 addresses in one call. Above 50 the
        request is refused with `400`; it is never truncated, because a
        playbook silently given part of its batch would decide on partial
        evidence.

        The public fields (`asn`, `as_org`, `country`, `cloud`) are present only
        for a publicly routable address, and `threat_intel` only for one that
        was actually checked: an empty list means "checked, no feed lists it",
        an absent key means "not applicable", and a single entry with
        `verdict: unavailable` means the enrichment database could not be
        consulted. Those are three different answers and a playbook must be able
        to tell them apart.
      parameters:
        - name: ip
          in: query
          required: true
          description: >-
            An IP address. Repeat the parameter for a batch, up to 50.
          schema:
            type: array
            maxItems: 50
            items: {type: string, example: "203.0.113.45"}
          style: form
          explode: true
      responses:
        "200":
          description: One entry per requested address, in request order
          content:
            application/json:
              schema: {$ref: "#/components/schemas/EnrichmentLookupResponse"}
              example:
                items:
                  - ip: "203.0.113.45"
                    scope: external
                    asn: 15169
                    as_org: Google LLC
                    country: US
                    cloud: {provider: aws, service: EC2, region: eu-west-3}
                    threat_intel:
                      - source: firehol_level1
                        verdict: match
                        updated_at: "2026-06-04T05:00:00Z"
                    carto: {resolved: false}
                  - ip: "10.0.0.50"
                    scope: internal
                    carto:
                      resolved: true
                      host: ci-runner-prod
                      interface: eth0
                      network: build
                      group: build
                      groups: [build]
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: obs_token
      description: "An API token minted for a user (`Authorization: Bearer obs_…`)."
  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: {type: string}
    Name:
      name: name
      in: path
      required: true
      schema: {type: string}
  responses:
    NoContent:
      description: >-
        Success, with no body. A client must not attempt to decode the response — there is nothing there to decode.
    Unauthorized:
      description: Missing, unknown, revoked or expired token
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Error"}
          example: {error: unauthenticated}
    Forbidden:
      description: The token's user lacks the required permission
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Error"}
          example: {error: forbidden}
    BadRequest:
      description: Malformed request (missing field, invalid JSON)
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Error"}
    NotFound:
      description: No such resource
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Error"}
  schemas:
    Error:
      type: object
      properties:
        error: {type: string}
      required: [error]
    ExecuteRequest:
      type: object
      required: [source]
      properties:
        source:
          type: string
          description: The NFQL pipeline to compile and execute
          example: "FROM sessions | LAST 3600 | STATS COUNT(*) AS n BY server_port"
        args:
          type: array
          description: Positional `?` arguments; integers are bound as integers
          items:
            type: [string, number, boolean, "null"]
            description: >-
              One bound argument. A JSON scalar — the compiler binds an integer as an integer, everything else by its JSON type.
        as_of:
          type: string
          format: date-time
          description: >
            Optional RFC3339 instant pinning the "now" of every relative window (the investigate-at-fire-time path). Empty = live now.

    ExecuteColumn:
      type: object
      properties:
        name: {type: string}
        type: {type: string, description: "DuckDB column type, e.g. INTEGER, BIGINT, VARCHAR"}
    ExecuteResponse:
      type: object
      properties:
        columns:
          type: array
          items: {$ref: "#/components/schemas/ExecuteColumn"}
        rows:
          type: array
          description: Row-major result; each row is an array aligned to `columns`
          items:
            type: array
            description: One row, positionally aligned with `columns`.
            items:
              type: [string, number, boolean, "null"]
              description: >-
                One cell. Its NFQL type is given by the `columns` entry at the same index; the JSON form is the scalar that type maps to.
        compile_ms: {type: integer, format: int64}
        exec_ms: {type: integer, format: int64}
        row_count: {type: integer}
        truncated: {type: boolean, description: True when the row cap was hit}
        error: {type: string, description: Populated (with columns/rows null) on a compile/exec error}
    AlertDetail:
      description: >-
        One alert plus the rows its rule matched. The evidence is why a single alert is worth fetching rather than listing.
      allOf:
        - $ref: "#/components/schemas/Alert"
        - type: object
          required: [columns, rows, records]
          properties:
            columns:
              type: array
              description: >-
                Names and types of the sampled fields, in the rule's `KEEP` order. Empty for an alert journaled before the v2 contract, which is exactly what an unnamed positional sample meant.
              items: {$ref: "#/components/schemas/AlertColumn"}
            rows:
              type: array
              description: >-
                The sample, row-major and positionally aligned with `columns`. Compact, and capped — `matched_count` is the real total.
              items:
                type: array
                description: One sampled row, positionally aligned with `columns`.
                items:
                  type: [string, number, boolean, "null"]
                  description: >-
                    One cell. Its NFQL type is given by the `columns` entry at the same index.
            records:
              type: array
              description: >-
                The same rows projected onto their column names. Read this one: addressing a field by name is what keeps a playbook working when someone edits the rule's `KEEP`.
              items:
                type: object
                description: >-
                  One sampled row, keyed by column name. The keys are the rule's `KEEP` columns and therefore vary per rule — that is the point of this projection, and `columns` names them.
                additionalProperties:
                  description: >-
                    A scalar as the query produced it (string, number, boolean or null). The matching `columns` entry gives its NFQL type.
    AlertColumn:
      type: object
      required: [name, type]
      properties:
        name: {type: string, example: client_ip}
        type:
          type: string
          description: >-
            The NFQL type as the engine spells it, so a consumer can coerce a value instead of guessing from its JSON form.
          example: VARCHAR
    HostInterface:
      type: object
      required: [name, network, ip]
      properties:
        name: {type: string, example: eth0}
        network: {type: string, description: The declared network this interface sits on.}
        ip: {type: string, example: "10.0.0.50"}
    HostService:
      type: object
      required: [name, protocol]
      properties:
        name: {type: string, example: ssh}
        protocol: {type: string, example: tcp}
        port: {type: integer, example: 22}
        interfaces:
          type: array
          description: Interfaces the service is exposed on. Absent means all of them.
          items: {type: string}
        description: {type: string}
        purpose:
          type: string
          description: A rule-pack service classification, when one was assigned.
    HostDetail:
      type: object
      description: One catalogued asset as the map declares it.
      required: [name, interfaces, services, groups]
      properties:
        name: {type: string}
        role:
          type: string
          description: The asset's declared role. Absent when unset.
        documentation:
          type: string
          description: Free-form markdown kept with the asset. Absent when unset.
        interfaces:
          type: array
          description: Empty, never null.
          items: {$ref: "#/components/schemas/HostInterface"}
        services:
          type: array
          description: Empty, never null.
          items: {$ref: "#/components/schemas/HostService"}
        groups:
          type: array
          description: >-
            Groups this host belongs to, resolved server-side and sorted by name. Empty, never null.
          items: {type: string}
    ResolvedIp:
      type: object
      description: >-
        What the cartography knows about an address. `kind` says how far the lookup got; the fields below it are present only as far as that.
      required: [ip, kind]
      properties:
        ip: {type: string, description: The address that was looked up, echoed back.: ''}
        kind:
          type: string
          enum: [host, network, unknown]
          description: >-
            `host` — a declared interface carries this address. `network` — it is inside a declared CIDR but unclaimed. `unknown` — nothing matches.
        host:
          type: string
          description: The catalogued asset name. Present only when `kind` is `host`.
        iface:
          type: string
          description: The interface carrying the address. Present only when `kind` is `host`.
        network:
          type: string
          description: The declared network. Present for both `host` and `network`.
    CartoSearchResult:
      type: object
      required: [kind, name, label, context]
      properties:
        kind: {type: string, enum: [host, group, network]}
        name: {type: string, description: The bare entity name.}
        label:
          type: string
          description: >-
            `<kind>:<name>` — the reference spelling every rule endpoint accepts, ready to be passed on unmodified.
        context:
          type: string
          description: >-
            A free-form qualification shown next to the result ("12 members", "10.0.0.0/24"). May be empty; never absent.
    CartoSearchResponse:
      type: object
      required: [query, matches]
      properties:
        query: {type: string, description: The query, echoed back.: ''}
        matches:
          type: array
          description: Best first. Empty, never null.
          items: {$ref: "#/components/schemas/CartoSearchResult"}
    FindCoveringRequest:
      type: object
      description: >-
        The flow to test against the matrix. At least one of `src` or `dst` is required; an omitted `port` or `protocol` means "any".
      properties:
        src:
          type: string
          description: >-
            `host:NAME`, `group:NAME`, `network:NAME`, or a bare IP address.
          example: "10.0.0.50"
        dst:
          type: string
          description: Same alphabet as `src`.
          example: "host:db-01"
        port: {type: integer, minimum: 0, maximum: 65535, example: 5432}
        protocol: {type: string, example: tcp, description: "Protocol name, e.g. tcp, udp, icmp."}
    FindCoveringResponse:
      type: object
      required: [matches]
      properties:
        matches:
          type: array
          description: >-
            Names of the rules that cover the flow. Empty means nothing does — an answer, not an error. Never null.
          items: {type: string}
        note:
          type: string
          description: >-
            A soft warning about the inputs, e.g. a named host with no interface to match on. Absent when there is nothing to say.
    PolicyCheckRequest:
      type: object
      description: >-
        One conversation. `src` and `dst` are both required and accept the same alphabet as a rule's own endpoints: `host:NAME`, `group:NAME`, `network:NAME`, or a bare IP address. An omitted `port` or `protocol` means "any".
      required: [src, dst]
      properties:
        src: {type: string, example: "10.0.0.50"}
        dst: {type: string, example: "host:db-01"}
        port: {type: integer, minimum: 1, maximum: 65535, example: 5432}
        protocol: {type: string, example: tcp, description: "Protocol name, e.g. tcp, udp, icmp."}
    Indicator:
      type: object
      description: >-
        One publicly routable address obserae's detection rules fired on, folded across every alert that saw it.
      required: [value, type, severity, alerts, first_seen, last_seen, rules]
      properties:
        value: {type: string, example: "198.51.100.7"}
        type: {type: string, enum: [ip]}
        severity:
          $ref: "#/components/schemas/AlertSeverity"
        alerts:
          type: integer
          description: How many alerts named this address in the window.
          example: 3
        first_seen: {type: string, format: date-time}
        last_seen: {type: string, format: date-time}
        rules:
          type: array
          description: >-
            Every rule that saw it, sorted. This is the provenance a human needs before acting on a line of a blocklist.
          items: {type: string}
    IndicatorFeed:
      type: object
      description: The `json` rendering of the feed, with its provenance.
      required: [generated_at, count, truncated, notice, indicators]
      properties:
        generated_at: {type: string, format: date-time}
        window: {type: string, example: 24h}
        instance:
          type: string
          description: >-
            Which obserae published this, so a merged blocklist says where each line came from.
        count: {type: integer}
        truncated:
          type: boolean
          description: The list was cut at the cap.
        notice:
          type: string
          description: >-
            The positioning statement, carried in the document itself: obserae publishes and does not block.
        indicators:
          type: array
          description: Most serious first, then most recent. Empty, never null.
          items: {$ref: "#/components/schemas/Indicator"}
    PeersRequest:
      type: object
      description: >-
        One "with whom" question. Only `ip` is required; every other field has a default, and every vocabulary is closed.
      required: [ip]
      properties:
        ip: {type: string, example: "10.0.0.50"}
        window:
          type: string
          enum: ["1h", "24h", "7d"]
          default: "24h"
          description: How far back to look. It is what bounds the scan.
        direction:
          type: string
          enum: [out, in, both]
          default: both
          description: >-
            `out` — conversations this address opened. `in` — conversations opened towards it.
        scope:
          type: string
          enum: [internal, external, any]
          default: any
          description: >-
            Narrow to peers on the public internet, or to peers that are not. The definition of "external" is the one the enrichment resolver uses.
        limit:
          type: integer
          minimum: 0
          maximum: 1000
          default: 100
          description: >-
            Clamped to 1000 rather than refused. A negative value is a mistake and is refused, since it would otherwise be answered with the default.
        as_of:
          type: string
          format: date-time
          description: >-
            RFC3339 anchor for the end of the window. Defaults to now. Pass an alert's `fired_at` to see the conversations that alert was about.
    PeersResponse:
      type: object
      description: The conversation partners matching the query, largest first.
      required: [window, as_of, peers]
      properties:
        window:
          type: string
          enum: ["1h", "24h", "7d"]
          description: The window actually applied, echoed back.
        as_of: {type: string, format: date-time}
        peers:
          type: array
          description: Largest first. Empty, never null.
          items: {$ref: "#/components/schemas/ContextPeer"}
        truncated:
          type: boolean
          description: >-
            The list was cut at the limit — the caller's, or the ceiling when the caller asked for more. Absent when it was not.
    ContextPeer:
      type: object
      description: One conversation partner, aggregated over the window.
      required: [peer, port, protocol, sessions, bytes, policy_covered]
      properties:
        peer: {type: string, example: "10.20.4.11"}
        port:
          type: integer
          description: The SERVER port of the conversation, whichever end the host was.
          example: 5432
        protocol: {type: string, example: tcp}
        sessions: {type: integer, format: int64, example: 2}
        bytes:
          type: integer
          format: int64
          description: Both directions, summed over the window.
          example: 8000
        first_seen: {type: string, format: date-time}
        last_seen: {type: string, format: date-time}
        direction:
          type: string
          enum: [out, in]
          description: >-
            Which way the conversation went. Present only where the answer is a single mixed list (`getPeers`); `getHostContext` splits the two into their own arrays, where repeating it would say nothing.
        policy_covered:
          type: boolean
          description: >-
            The flow matrix's verdict on this exact conversation — the same one `checkPolicy` gives for the same pair.
    ContextService:
      type: object
      description: One port this host served during the window.
      required: [port, protocol, clients, sessions, bytes]
      properties:
        port: {type: integer, example: 22}
        protocol: {type: string, example: tcp}
        name:
          type: string
          description: >-
            The cartography's name for the service, as it was stamped on the session when it closed. Absent when the map did not know it then; these annotations are frozen at observation time and never rewritten.
          example: ssh
        clients: {type: integer, format: int64}
        sessions: {type: integer, format: int64}
        bytes: {type: integer, format: int64}
        last_seen: {type: string, format: date-time}
    ContextVolume:
      type: object
      description: >-
        Oriented on the HOST, not on whoever happened to be the client: `out_bytes` is what the host sent, whichever end of the conversation it was.
      required: [in_bytes, out_bytes]
      properties:
        in_bytes: {type: integer, format: int64}
        out_bytes: {type: integer, format: int64}
    HostContextResponse:
      type: object
      description: >-
        One host's recent network behaviour. Every list is present and empty rather than null, so a client can iterate unconditionally.
      required: [window, as_of, services_exposed, talks_to, talked_to_by, volume, new_peers_in_window]
      properties:
        entity: {$ref: "#/components/schemas/EnrichmentLookupEntry"}
        window:
          type: string
          enum: ["1h", "24h", "7d"]
          description: The window actually applied, echoed back.
        as_of:
          type: string
          format: date-time
          description: >-
            The instant the window ends, echoed back — so a stored answer says what it is an answer to.
        services_exposed:
          type: array
          items: {$ref: "#/components/schemas/ContextService"}
        talks_to:
          type: array
          description: Conversations this host opened. Volume first, capped at 100.
          items: {$ref: "#/components/schemas/ContextPeer"}
        talked_to_by:
          type: array
          description: Conversations opened TOWARDS this host. Volume first, capped at 100.
          items: {$ref: "#/components/schemas/ContextPeer"}
        talks_to_truncated:
          type: boolean
          description: The outbound list was cut at its cap. Absent when it was not.
        talked_to_by_truncated:
          type: boolean
          description: The inbound list was cut at its cap. Absent when it was not.
        volume: {$ref: "#/components/schemas/ContextVolume"}
        new_peers_in_window:
          type: array
          description: >-
            Addresses this host did NOT talk to in the equally long period before the window. That definition is what keeps the scan bounded — a literal "never before" would read the whole retained history.
          items: {type: string}
    PolicyNearMiss:
      type: object
      description: >-
        A rule that would cover the flow if ONE dimension were relaxed, and which one. This is what turns a refusal into something an analyst can act on.
      required: [name, why_not]
      properties:
        name: {type: string, example: build-to-db}
        why_not:
          type: string
          description: >-
            The single dimension standing between this rule and the flow. The taxonomy is closed: a client may switch on these four values and will never meet a fifth. When a rule differs on several dimensions it is reported once, under the first of this list.
          enum:
            - port not in rule
            - protocol mismatch
            - source not in scope
            - destination not in scope
          example: port not in rule
    PolicyCheckResponse:
      type: object
      description: >-
        The flow matrix's verdict on one conversation, with both endpoints resolved to their assets so the answer can go into a ticket as it is.
      required: [covered, matched_rule, nearest_rules]
      properties:
        covered:
          type: boolean
          description: >-
            Whether the flow matrix already allows this conversation. A reference that resolved to no address is `false`, never silently allowed.
        matched_rule:
          type: [string, "null"]
          description: >-
            The covering rule, or **null** when nothing covers the flow. Explicitly null rather than absent, so a client reads a value in both cases. When several rules cover the same conversation this is the alphabetically first, so two calls quote the same rule.
          example: build-to-db
        nearest_rules:
          type: array
          description: >-
            The rules that came closest, most actionable reason first, then by name — so two identical calls produce the same ticket. Empty (never null) when the flow is covered, since there is nothing to explain. Capped at 10.
          maxItems: 10
          items: {$ref: "#/components/schemas/PolicyNearMiss"}
        nearest_rules_truncated:
          type: boolean
          description: >-
            The list was cut at the cap. Absent when it was not — a caller reading a short list can then tell "that is all of them" from "that is the first ten of them".
        src_entity: {$ref: "#/components/schemas/EnrichmentLookupEntry"}
        dst_entity: {$ref: "#/components/schemas/EnrichmentLookupEntry"}
        note:
          type: string
          description: >-
            A soft warning about the inputs — a name that resolved to nothing, a host with no interface. Absent when there is nothing to say.
    EnrichmentCarto:
      type: object
      description: >-
        Where the map places the address. An unresolved address carries `resolved: false` and NOTHING else — never an empty host name, so a playbook testing `carto.host` cannot read "" as an asset.
      required: [resolved]
      properties:
        resolved: {type: boolean}
        host: {type: string, example: ci-runner-prod}
        interface: {type: string, example: eth0}
        network: {type: string, example: build}
        group:
          type: string
          description: >-
            The first of `groups`, so the singular and the plural can never disagree.
          example: build
        groups:
          type: array
          items: {type: string}
    EnrichmentCloud:
      type: object
      description: The published provider range the address falls in.
      required: [provider]
      properties:
        provider:
          type: string
          description: The matching source's own name — aws, azure, google, oracle, cloudflare.
          example: aws
        service: {type: string, example: EC2}
        region: {type: string, example: eu-west-3}
    ThreatIntelHit:
      type: object
      description: >-
        One threat feed's opinion on an address, or the fact that no feed could be consulted.
      required: [verdict]
      properties:
        source:
          type: string
          description: The enrichment source that listed the address.
          example: firehol_level1
        verdict:
          type: string
          enum: [match, unavailable]
          description: >-
            `match` — this feed lists the address. `unavailable` — the enrichment database could not be consulted, so nothing was checked. The one conclusion a playbook must never draw from `unavailable` is "clean".
        list:
          type: string
          description: >-
            The feed's own annotation for the entry (a Tor nickname, a malware family). Absent for a feed that publishes bare ranges.
        updated_at:
          type: string
          format: date-time
          description: >-
            When that source last refreshed. Absent when it has never fetched — a playbook deciding on a listing needs to know how old it is.
    EnrichmentLookupEntry:
      type: object
      description: >-
        One address. The public fields are present only for a publicly routable address; `carto` is always present.
      required: [ip, scope, carto]
      properties:
        ip: {type: string, example: "203.0.113.45"}
        scope: {type: string, enum: [internal, external]}
        asn: {type: integer, example: 15169}
        as_org: {type: string, example: Google LLC}
        country:
          type: string
          description: ISO-3166 alpha-2, uppercase.
          example: US
        cloud: {$ref: "#/components/schemas/EnrichmentCloud"}
        detail:
          type: object
          additionalProperties: {type: string}
          description: >-
            Annotations no parser recognised, keyed by source. A feed changing its format degrades the shape of the answer, never its existence.
        threat_intel:
          type: array
          description: >-
            Empty means "checked, no feed lists it". Absent means the address was not checked (an internal address is never checked).
          items: {$ref: "#/components/schemas/ThreatIntelHit"}
        carto: {$ref: "#/components/schemas/EnrichmentCarto"}
    EnrichmentLookupResponse:
      type: object
      description: >-
        An envelope, so the response type does not change with the number of `ip` parameters sent.
      required: [items]
      properties:
        items:
          type: array
          description: One entry per requested address, in request order. Never null.
          items: {$ref: "#/components/schemas/EnrichmentLookupEntry"}
    AlertStatusRequest:
      type: object
      required: [status]
      properties:
        status: {$ref: "#/components/schemas/AlertStatus"}
    AlertSeverity:
      type: string
      enum: [info, low, medium, high, critical]
      description: >-
        The rule's declared severity, carried by every alert it fires.
    AlertStatus:
      type: string
      enum: [new, ack, closed]
      description: >-
        Where the alert stands. `new` — nobody has taken it. `ack` — an operator owns it. `closed` — the condition is gone, or it was closed by hand. Deleted alerts are not a status: they stop being returned.
    Alert:
      type: object
      description: One emitted alert, as the Detection page and the API read it.
      required: [id, rule_id, rule_name, fired_at, severity, matched_count, status]
      properties:
        id:
          type: string
          format: uuid
          description: Unique per occurrence.
        rule_id:
          type: string
          format: uuid
          description: >-
            The rule that fired. Immutable — this is the correlation key, not `rule_name`.
        rule_name:
          type: string
          description: The rule's name at the time it is read. Editable.
        fired_at: {type: string, format: date-time}
        severity: {$ref: "#/components/schemas/AlertSeverity"}
        matched_count:
          type: integer
          format: int64
          description: >-
            Rows the rule's query matched. The real total, not the size of the sample carried in `detail`.
        observed_value:
          type: number
          format: double
          description: >-
            The metric the condition compared. Present on threshold and anomaly rules only; absent otherwise rather than zero.
        key_json:
          type: string
          description: >-
            The grouping entity as a JSON object, e.g. `{"client_ip":"10.0.0.50"}`. Empty on a rule with no `group_by`.
        status: {$ref: "#/components/schemas/AlertStatus"}
    AlertList:
      type: object
      description: >-
        A page of alerts and the total behind it. Never a bare array: a truncated array cannot tell a client whether more remain.
      required: [items, total, limit, offset]
      properties:
        items:
          type: array
          description: The page, most recent first. Empty, never null.
          items: {$ref: "#/components/schemas/Alert"}
        total:
          type: integer
          format: int64
          description: >-
            Every alert the filter matched, independent of `limit`/`offset`.
        limit:
          type: integer
          description: >-
            The page size actually applied. An over-large request is clamped to 10000 rather than rejected, and this field is where the caller learns it.
        offset: {type: integer, description: Rows skipped, echoed back.: ''}
    AlertCounts:
      type: object
      properties:
        total: {type: integer, format: int64}
        by_status:
          type: object
          additionalProperties: {type: integer, format: int64}
        by_severity:
          type: object
          additionalProperties: {type: integer, format: int64}
        last_1h: {type: integer, format: int64}
        last_24h: {type: integer, format: int64}
    StorageInfo:
      type: object
      properties:
        db_path: {type: string}
        db_size_bytes: {type: integer, format: int64}
        disk_free_bytes: {type: integer, format: int64}
        disk_total_bytes: {type: integer, format: int64}
        buffer_dir: {type: string}
        buffer_dir_bytes: {type: integer, format: int64}
        buffer_dir_files: {type: integer}
        backup_dir: {type: string}
        backup_dir_bytes: {type: integer, format: int64}
        backup_dir_files: {type: integer}
    Status:
      type: object
      description: The daemon status snapshot returned by GET /api/status.
      properties:
        version: {type: string}
        commit: {type: string}
        started_at: {type: string, format: date-time}
        uptime_seconds: {type: integer, format: int64}
        timestamp: {type: string, description: RFC3339 collection instant}
        data_version: {type: integer}
        data_version_target: {type: integer}
        flows:
          type: object
          properties:
            total: {type: integer, format: int64}
            per_sec: {type: number, format: double}
        cartography:
          type: object
          properties:
            networks: {type: integer, format: int64}
            hosts: {type: integer, format: int64}
            services: {type: integer, format: int64}
            groups: {type: integer, format: int64}
        sessions:
          type: object
          properties:
            active: {type: integer, format: int64}
            half_open: {type: integer, format: int64}
            closed: {type: integer, format: int64}
            open_live: {type: integer, format: int64}
            open_max: {type: integer, format: int64}
            fill_pct: {type: number, format: double}
            pressure: {type: string}
            evicted: {type: integer, format: int64}
            dropped_closes: {type: integer, format: int64}
            dropped_emits: {type: integer, format: int64}
            dropped_nat_observations: {type: integer, format: int64}
        enrich:
          type: object
          properties:
            lru_used: {type: integer, format: int64}
            lru_cap: {type: integer, format: int64}
            # Boot priming of the enrichment CIDR tries runs in the
            # background. primed_sources < prime_total means enrichment is
            # still incomplete, so a thin result is "not ready yet" rather
            # than "no match".
            primed_sources: {type: integer, format: int64}
            prime_total: {type: integer, format: int64}
        templates:
          type: object
          properties:
            received: {type: integer, format: int64}
            packets_awaiting: {type: integer, format: int64}
        rules:
          type: object
          properties:
            total: {type: integer, format: int64}
            expansions: {type: integer, format: int64}
            alert_rules_active: {type: integer, format: int64}
        coverage:
          type: object
          properties:
            rate_1h: {type: number, format: double}
            violations_closed_1h: {type: integer, format: int64}
        alerts: {$ref: "#/components/schemas/AlertCounts"}
        ingestion:
          type: object
          properties:
            files_written: {type: integer, format: int64}
            records_ingested: {type: integer, format: int64}
            last_flush_at: {type: string}
            avg_flush_ms: {type: number, format: double}
            max_flush_ms: {type: integer, format: int64}
        storage: {$ref: "#/components/schemas/StorageInfo"}
        runtime:
          type: object
          properties:
            heap_inuse_bytes: {type: integer, format: int64}
            sys_bytes: {type: integer, format: int64}
            goroutines: {type: integer}
            udp_drops: {type: integer, format: int64}
            matcher_cursor_lag_seconds: {type: integer, format: int64}
        audit:
          type: object
          properties:
            integrity: {type: string}
            breaks: {type: integer}
        live_metrics_available:
          type: boolean
          description: False on a headless daemon (web.enabled=false) — producer-derived blocks stay zero
