openapi: 3.0.3
info:
  title: qf Control Plane API
  description: >
    REST API for the qf host-based firewall control plane.


    **Authentication**: Most endpoints require either a JWT cookie (`qf_token`,
    set by `POST /auth/login`)

    or an `Authorization: Bearer <token>` header (JWT or opaque API token).


    **Tenant**: Protected endpoints require the `X-Tenant-ID` header (UUID of
    the tenant).
  version: 0.20.0
servers:
  - url: http://localhost:8080
    description: Local development
tags:
  - name: Auth
    description: Authentication and session management
  - name: Hosts
    description: Managed firewall hosts
  - name: HostGroups
    description: Named host groups with shared labels
  - name: Events
    description: Log events, flow events, and counters per host
  - name: Policies
    description: Firewall policies and rules
  - name: Versions
    description: Cross-entity config-version browser and impact preview
  - name: ObjectGroups
    description: Named IP/port/host sets for use in rules
  - name: Tokens
    description: Agent enrollment tokens
  - name: DefaultPolicy
    description: Tenant-level default allow/deny actions
  - name: AuditLog
    description: API audit trail
  - name: Users
    description: User management (Admin role required for mutations)
  - name: APITokens
    description: Long-lived API tokens for automation
security:
  - cookieAuth: []
  - bearerAuth: []
components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: qf_token
    bearerAuth:
      type: http
      scheme: bearer
  parameters:
    TenantID:
      name: X-Tenant-ID
      in: header
      required: true
      description: Tenant UUID
      schema:
        type: string
        format: uuid
    ResourceID:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        default: 500
        maximum: 1000
    StartTime:
      name: start
      in: query
      schema:
        type: string
        format: date-time
    EndTime:
      name: end
      in: query
      schema:
        type: string
        format: date-time
    ChangedSince:
      name: changed_since
      in: query
      required: false
      description: >
        Reconnect-backfill delta cursor (UI live-sync). When present, the
        endpoint returns ONLY rows changed at or after `changed_since - OVERLAP`
        (a small server-applied safety window; client dedups by id),
        tenant-scoped exactly as the full list. Omit for the normal full-list
        response (unchanged). Format is RFC3339 with nanosecond precision
        (RFC3339Nano) so same-second bulk-enroll ties are preserved. For hosts,
        archived (soft-deleted) rows are surfaced as tombstones (archived_at
        set) so clients reconcile deletes. For tokens the filter is insert-only
        (bootstrap_tokens key on created_at).
      schema:
        type: string
        format: date-time
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
        request_id:
          type: string
          description: >
            Echoes the X-Request-Id response header (chi request id). Present
            whenever the request passed through the request-id middleware;
            omitted (never emitted as an empty string) otherwise, e.g. in
            unit-test contexts that bypass it.
      required:
        - error
    Host:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        hostname:
          type: string
        labels:
          type: object
          additionalProperties:
            type: string
          description: Labels set directly on the host
        effective_labels:
          type: object
          additionalProperties:
            type: string
          description: >
            Merged labels: group labels (lower priority number wins) with
            host.labels applied on top.

            Read-only computed field. Use host.labels for mutation.
        status:
          type: string
          enum:
            - enrolling
            - active
            - stale
            - needs_rebootstrap
            - revoked
        current_generation:
          type: integer
        desired_generation:
          type: integer
        config_version:
          type: integer
          description: >
            Last applied host_labels config-revision (MAX applied version of the

            host's label history). This is NOT the bundle generation

            (current/desired/last_confirmed_generation, which track datapath

            convergence). Derive-on-read, detail route (GET /hosts/{id}) only;
            omitted

            when the host has no applied label history yet.
        flow_events_enabled:
          type: boolean
        last_heartbeat_at:
          type: string
          format: date-time
          nullable: true
        agent_version:
          type: string
          nullable: true
        kernel_version:
          type: string
          nullable: true
        last_seen_ip:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateHostRequest:
      type: object
      required:
        - hostname
      properties:
        hostname:
          type: string
        labels:
          type: object
          additionalProperties:
            type: string
        status:
          type: string
          default: enrolling
    PatchHostRequest:
      type: object
      properties:
        labels:
          type: object
          additionalProperties:
            type: string
        status:
          type: string
        flow_events_enabled:
          type: boolean
    RuleCapSummary:
      type: object
      description: VIZ-02 rule-cap cohort distribution, aggregated server-side per tenant.
      properties:
        tiers:
          type: array
          description: Present cap tiers only, ordered 32, 2048, other.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                  - '32'
                  - '2048'
                  - other
                description: Cap tier key.
              cap:
                type: integer
                description: >-
                  Representative cap for the tier (0 for the mixed 'other'
                  tier).
              is_other:
                type: boolean
                description: >-
                  True for the mixed 'other' tier (resolved cap outside
                  32/2048).
              total:
                type: integer
                description: Hosts with a resolvable used+cap in this tier.
              cohorts:
                type: object
                description: >
                  Per-cohort host counts keyed by cohort id (over, saturated,
                  near, mid, low). over = used > cap; saturated = used == cap;
                  near/mid/low are utilization ratio bands.
                additionalProperties:
                  type: integer
            required:
              - key
              - cap
              - is_other
              - total
              - cohorts
        no_metrics:
          type: integer
          description: Hosts with no resolvable rule-cap (fresh / never heartbeated).
      required:
        - tiers
        - no_metrics
    HostGroup:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        labels:
          type: object
          additionalProperties:
            type: string
          description: Labels inherited by member hosts (overridable by host.labels)
        priority:
          type: integer
          description: Lower number = higher priority; wins label conflicts between groups
        member_count:
          type: integer
          description: Number of member hosts (list response only)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateHostGroupRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        labels:
          type: object
          additionalProperties:
            type: string
        priority:
          type: integer
          default: 100
    UpdateHostGroupRequest:
      type: object
      properties:
        name:
          type: string
        labels:
          type: object
          additionalProperties:
            type: string
        priority:
          type: integer
    HostGroupMemberRequest:
      type: object
      required:
        - host_id
      properties:
        host_id:
          type: string
          format: uuid
    RulesetRuleItem:
      type: object
      properties:
        rule_id:
          type: string
          format: uuid
        rule_name:
          type: string
        policy_id:
          type: string
          format: uuid
        policy_name:
          type: string
        priority:
          type: integer
        direction:
          type: string
          enum:
            - ingress
            - egress
        action:
          type: string
          enum:
            - allow
            - deny
            - log
        protocol:
          type: string
          nullable: true
        src_cidrs:
          type: array
          items:
            type: string
          nullable: true
        dst_cidrs:
          type: array
          items:
            type: string
          nullable: true
        src_ports:
          type: array
          items:
            type: string
          nullable: true
        dst_ports:
          type: array
          items:
            type: string
          nullable: true
    EffectiveRuleset:
      type: object
      properties:
        host_id:
          type: string
          format: uuid
        default_ingress:
          type: string
          enum:
            - allow
            - deny
        default_egress:
          type: string
          enum:
            - allow
            - deny
        rules:
          type: array
          items:
            $ref: '#/components/schemas/RulesetRuleItem'
    Policy:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        priority:
          type: integer
        selector:
          type: object
        confirm_match_all:
          type: boolean
          description: >
            Label-selector to match hosts. Example: `{"matchLabels": {"env":
            "prod"}}`.

            Empty selector matches all hosts.
        current_version:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PolicyGraphBucket:
      type: object
      description: A group of active hosts a policy can be evaluated against.
      properties:
        id:
          type: string
          description: >
            Bucket identifier: a host-group UUID (hostgroup mode),
            `label:<key>=<val>` (label mode), a host UUID (none mode), or the
            synthetic `__ungrouped__` / `__unset__` sentinels.
        name:
          type: string
          description: Human-readable bucket label (group name, label value, or hostname).
        kind:
          type: string
          enum:
            - group
            - label
            - host
        total_hosts:
          type: integer
          description: Active hosts in this bucket (the coverage denominator).
    PolicyGraphEdge:
      type: object
      description: How many hosts of a bucket a policy currently matches.
      properties:
        policy_id:
          type: string
          format: uuid
        bucket_id:
          type: string
        matched_hosts:
          type: integer
          description: Active hosts in the bucket matched by the policy (≤ total_hosts).
    PolicyGraphMeta:
      type: object
      properties:
        label_keys:
          type: array
          items:
            type: string
          description: >-
            Distinct effective-label keys across active hosts, most-covered
            first.
        group_by:
          type: string
          description: >-
            The resolved bucket dimension (`hostgroup`, `none`, or
            `label:<key>`).
        node_count:
          type: integer
          description: >-
            Exact number of nodes the client would lay out for this response
            (policy nodes + bucket nodes). The UI reads this BEFORE attempting a
            force layout so it can fall back to the matrix over the node cap
            (~150) instead of rendering an unreadable hairball. Read-only REST
            meta field — no proto and no datapath change, so the BPF datapath
            (single TcFilter build) is untouched.
    PolicyGraph:
      type: object
      properties:
        policies:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
              priority:
                type: integer
        buckets:
          type: array
          items:
            $ref: '#/components/schemas/PolicyGraphBucket'
        edges:
          type: array
          items:
            $ref: '#/components/schemas/PolicyGraphEdge'
        meta:
          $ref: '#/components/schemas/PolicyGraphMeta'
    PolicyCoverage:
      type: object
      description: >
        One policy's active-host reach + rule count for the policies-list
        columns. direct_hosts + group_hosts can exceed total_hosts because a
        host may match both a direct and a group block; total_hosts (the
        distinct union) is the true blast radius.
      properties:
        policy_id:
          type: string
          format: uuid
        direct_hosts:
          type: integer
          description: >-
            Active hosts matched by a direct block (pinned host or bare label
            block).
        group_hosts:
          type: integer
          description: Active hosts matched by a host-group block.
        total_hosts:
          type: integer
          description: >-
            Distinct active hosts matched by the whole selector (direct ∪
            group).
        rules_count:
          type: integer
          description: Live rule count; 0 = an inert policy.
    CreatePolicyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string
        priority:
          type: integer
          default: 0
        selector:
          type: object
    UpdatePolicyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string
        priority:
          type: integer
        selector:
          type: object
        rules:
          type: array
          items:
            $ref: '#/components/schemas/CreateRuleRequest'
        confirm_match_all:
          type: boolean
    RuleMatch:
      type: object
      additionalProperties: true
      description: >
        BPF match criteria. Object-group references use arrays so every
        reference

        is stored in policy versions and restored atomically. Legacy singular

        `*_set_id` keys are accepted and returned as canonical one-element
        arrays.
      properties:
        protocol:
          type: string
          enum:
            - tcp
            - udp
            - icmp
            - icmpv6
            - any
        src_cidrs:
          type: array
          description: >
            Inline source CIDR/IP list. Accepts CIDR-prefix and IPv4
            dotted-netmask (`192.168.1.0/255.255.255.0`) notation;
            dotted-netmask is canonicalized to `/prefix` on write.
          items:
            type: string
        dst_cidrs:
          type: array
          description: Inline destination CIDR/IP list (same notation as src_cidrs).
          items:
            type: string
        src_ports:
          type: array
          items:
            type: string
        dst_ports:
          type: array
          items:
            type: string
        src_ip_set_ids:
          type: array
          items:
            type: string
            format: uuid
        dst_ip_set_ids:
          type: array
          items:
            type: string
            format: uuid
        src_port_set_ids:
          type: array
          items:
            type: string
            format: uuid
        dst_port_set_ids:
          type: array
          items:
            type: string
            format: uuid
        src_host_set_ids:
          type: array
          items:
            type: string
            format: uuid
        dst_host_set_ids:
          type: array
          items:
            type: string
            format: uuid
        src_ip_set_id:
          type: string
          format: uuid
          deprecated: true
        dst_ip_set_id:
          type: string
          format: uuid
          deprecated: true
        src_port_set_id:
          type: string
          format: uuid
          deprecated: true
        dst_port_set_id:
          type: string
          format: uuid
          deprecated: true
        src_host_set_id:
          type: string
          format: uuid
          deprecated: true
        dst_host_set_id:
          type: string
          format: uuid
          deprecated: true
        tcp_flags_mask:
          type: integer
          format: int32
        tcp_flags_match:
          type: integer
          format: int32
    Rule:
      type: object
      properties:
        id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        name:
          type: string
        priority:
          type: integer
        direction:
          type: string
          enum:
            - ingress
            - egress
        match:
          $ref: '#/components/schemas/RuleMatch'
        state:
          type: string
          nullable: true
          deprecated: true
          description: DEPRECATED — use `states`. Legacy single conntrack state filter.
        states:
          type: array
          items:
            type: string
            enum:
              - new
              - established
              - related
              - invalid
          description: >-
            Set of conntrack states this rule matches (any of). One rule can
            match e.g. established+related. Empty = stateless.
        action:
          type: string
          enum:
            - allow
            - deny
            - log
        log:
          type: boolean
        silent:
          type: boolean
        notrack:
          type: boolean
          description: >-
            Skip conntrack insert for flows this rule ALLOWs (saves LRU slots on
            high-volume stateless traffic). Does not change the verdict.
        log_rate_limit_per_sec:
          type: integer
          format: int32
          description: >-
            Per-rule log token bucket, events/sec. 0 means "use the fleet
            default" (AgentConfig.default_log_rate_limit_per_sec, 100/s) — NOT
            "no logging". The datapath field is 16-bit, so values above 65535
            truncate.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateRuleRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        priority:
          type: integer
          default: 0
        direction:
          type: string
          enum:
            - ingress
            - egress
          default: ingress
        match:
          $ref: '#/components/schemas/RuleMatch'
        state:
          type: string
          nullable: true
          deprecated: true
        states:
          type: array
          items:
            type: string
            enum:
              - new
              - established
              - related
              - invalid
          description: >-
            Set of conntrack states this rule matches (any of). Empty =
            stateless.
        action:
          type: string
          enum:
            - allow
            - deny
            - log
          default: deny
        log:
          type: boolean
          default: false
        silent:
          type: boolean
        notrack:
          type: boolean
          description: >-
            Skip conntrack insert for flows this rule ALLOWs (saves LRU slots on
            high-volume stateless traffic). Does not change the verdict.
          default: false
        log_rate_limit_per_sec:
          type: integer
          format: int32
          default: 0
          description: >-
            Per-rule log token bucket, events/sec. 0 means "use the fleet
            default" (AgentConfig.default_log_rate_limit_per_sec, 100/s) — NOT
            "no logging". The datapath field is 16-bit, so values above 65535
            truncate.
    UpdateRuleRequest:
      allOf:
        - $ref: '#/components/schemas/CreateRuleRequest'
    PolicyVersion:
      type: object
      properties:
        id:
          type: string
          format: uuid
        version:
          type: integer
        content:
          type: object
          description: Full policy+rules snapshot
        created_by:
          type: string
        created_at:
          type: string
          format: date-time
    DryRunRuleSpec:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Existing rule UUID; omit for new rules
        name:
          type: string
        priority:
          type: integer
        direction:
          type: string
          enum:
            - ingress
            - egress
        match:
          $ref: '#/components/schemas/RuleMatch'
        state:
          type: string
          nullable: true
          deprecated: true
        states:
          type: array
          items:
            type: string
            enum:
              - new
              - established
              - related
              - invalid
          description: >-
            Set of conntrack states this rule matches (any of). Empty =
            stateless.
        action:
          type: string
          enum:
            - allow
            - deny
            - log
        log:
          type: boolean
        silent:
          type: boolean
        notrack:
          type: boolean
          description: >-
            Skip conntrack insert for flows this rule ALLOWs (saves LRU slots on
            high-volume stateless traffic). Does not change the verdict.
        log_rate_limit_per_sec:
          type: integer
          format: int32
          description: >-
            Per-rule log token bucket, events/sec. 0 means "use the fleet
            default" (AgentConfig.default_log_rate_limit_per_sec, 100/s) — NOT
            "no logging".
    PreviewRequest:
      type: object
      required:
        - rules
      properties:
        rules:
          type: array
          items:
            $ref: '#/components/schemas/DryRunRuleSpec'
    HostDiff:
      type: object
      properties:
        id:
          type: string
          format: uuid
        hostname:
          type: string
        added:
          type: array
          items:
            type: string
        removed:
          type: array
          items:
            type: string
        changed:
          type: array
          items:
            type: string
        changed_detail:
          type: array
          description: Per-changed-rule field-level before/after (match-aware).
          items:
            $ref: '#/components/schemas/RuleFieldDiff'
        added_detail:
          type: array
          description: >
            B-09 full after-side field snapshot of each ADDED rule, so the
            impact panel shows what the rule opens instead of an empty diff.
            Additive/omitempty.
          items:
            $ref: '#/components/schemas/RuleFieldSnapshot'
        removed_detail:
          type: array
          description: >
            B-09 full before-side field snapshot of each REMOVED rule (value→∅).
            rule_id is the before-side live id (same key as removed_ids).
          items:
            $ref: '#/components/schemas/RuleFieldSnapshot'
        removed_ids:
          type: array
          description: Before-side rule ids of the removed rules (traffic-join key).
          items:
            type: string
            format: uuid
    RuleFieldDiff:
      type: object
      description: One changed rule's field-level attribution (only differing fields).
      properties:
        rule_id:
          type: string
          format: uuid
        name:
          type: string
        fields:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              before:
                type: string
              after:
                type: string
    RuleFieldSnapshot:
      type: object
      description: >
        B-09 full one-sided field snapshot of an added/removed rule — every
        resolved field's single value (unlike RuleFieldDiff which lists only
        differing fields).
      properties:
        rule_id:
          type: string
          description: Before-side live id for a removed rule; empty for an added rule.
        name:
          type: string
        fields:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              value:
                type: string
    PreviewResult:
      type: object
      properties:
        affected_count:
          type: integer
        hosts:
          type: array
          items:
            $ref: '#/components/schemas/HostDiff'
    BlastImpact:
      type: object
      description: >
        B-12 stage-time blast preview: the write-free per-host impact of a
        staged draft PLUS the observed log_events traffic for the affected
        before-side rule ids over a bounded window — the same {blast, traffic,
        window} shape the /versions impact panel uses, minus the applied-version
        identity. `traffic` is empty for a pure-add change OR when the traffic
        aggregate degraded server-side (the diff is still returned — the preview
        never 500s on a traffic failure). `window` echoes the effective
        [from,to) after server defaulting (24h) / capping (7d).
      properties:
        blast:
          type: object
          description: >-
            Affected-host set + per-host match-aware diff (per-host `hosts`
            detail for policy, object_group and host_group edits; host_labels
            stays count-only host_ids).
          properties:
            affected_count:
              type: integer
            host_ids:
              type: array
              items:
                type: string
                format: uuid
            hosts:
              type: array
              items:
                $ref: '#/components/schemas/HostDiff'
        traffic:
          type: array
          description: Decisions per affected before-side rule over the window.
          items:
            type: object
            properties:
              rule_id:
                type: string
                format: uuid
              decisions:
                type: integer
                format: int64
        window:
          type: object
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
    ObjectGroup:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        type:
          type: string
          enum:
            - ipset
            - portset
            - hostset
        name:
          type: string
        spec:
          type: object
          description: |
            Group definition. Examples:
            - ipset: `{"cidrs": ["10.0.0.0/8", "192.168.1.0/24"]}`
            - portset: `{"ports": [80, 443, 8080]}`
            - hostset: `{"hostnames": ["db.internal", "redis.internal"]}`

            ipset `cidrs` entries accept both CIDR-prefix (`192.168.1.0/24`) and
            IPv4 dotted-netmask (`192.168.1.0/255.255.255.0`) notation. A
            dotted-netmask is canonicalized to prefix form on write, so it is
            stored and returned as `/prefix`. Non-contiguous masks
            (e.g. `255.0.255.0`) are rejected.
        resolved_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateObjectGroupRequest:
      type: object
      required:
        - type
        - name
      properties:
        type:
          type: string
          enum:
            - ipset
            - portset
            - hostset
        name:
          type: string
        spec:
          type: object
    UpdateObjectGroupRequest:
      type: object
      required:
        - spec
      properties:
        spec:
          type: object
    Token:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        type:
          type: string
          enum:
            - single_host
            - bulk
        target_host_id:
          type: string
          format: uuid
          nullable: true
        target_group_id:
          type: string
          format: uuid
          nullable: true
          description: bulk only — enrolling host is auto-added to this host-group
        label_template:
          type: object
          additionalProperties:
            type: string
        max_uses:
          type: integer
        uses_count:
          type: integer
        expires_at:
          type: string
          format: date-time
        token:
          type: string
          description: Plain token value — only present in create response
    CreateTokenRequest:
      type: object
      required:
        - type
      properties:
        type:
          type: string
          enum:
            - single_host
            - bulk
        target_host_id:
          type: string
          format: uuid
          description: Required when type=single_host
        target_group_id:
          type: string
          format: uuid
          description: >-
            bulk only — auto-add the enrolling host to this host-group (must be
            in tenant)
        label_template:
          type: object
          additionalProperties:
            type: string
        ttl_seconds:
          type: integer
          default: 3600
        max_uses:
          type: integer
          default: 1
    DefaultPolicy:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        default_ingress_action:
          type: string
          enum:
            - allow
            - deny
        default_egress_action:
          type: string
          enum:
            - allow
            - deny
        updated_at:
          type: string
          format: date-time
    PutDefaultPolicyRequest:
      type: object
      properties:
        default_ingress_action:
          type: string
          enum:
            - allow
            - deny
          default: deny
        default_egress_action:
          type: string
          enum:
            - allow
            - deny
          default: deny
    SystemEvent:
      type: object
      description: >-
        Host lifecycle/security event (datapath_drift, host_unreachable,
        cp_connected, …).
      properties:
        id:
          type: string
          format: uuid
        host_id:
          type: string
          format: uuid
        event_type:
          type: string
          example: datapath_drift
        severity:
          type: string
          enum:
            - info
            - warning
            - error
        detail:
          type: string
          nullable: true
        ts:
          type: string
          format: date-time
    LogEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        host_id:
          type: string
          format: uuid
        rule_id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        rule_name:
          type: string
          nullable: true
        policy_name:
          type: string
          nullable: true
        direction:
          type: string
          enum:
            - ingress
            - egress
        action:
          type: string
          enum:
            - allow
            - deny
            - log
        protocol:
          type: integer
          description: IP protocol number (6=TCP, 17=UDP)
        src_ip:
          type: string
        src_port:
          type: integer
          nullable: true
        dst_ip:
          type: string
        dst_port:
          type: integer
          nullable: true
        packet_size:
          type: integer
          nullable: true
        tcp_flags:
          type: integer
          nullable: true
        ct_state:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
    FlowEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        host_id:
          type: string
          format: uuid
        protocol:
          type: integer
        src_ip:
          type: string
        src_port:
          type: integer
          nullable: true
        dst_ip:
          type: string
        dst_port:
          type: integer
          nullable: true
        bytes_orig:
          type: integer
          format: int64
        bytes_reply:
          type: integer
          format: int64
        packets_orig:
          type: integer
          format: int64
        packets_reply:
          type: integer
          format: int64
        final_state:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        ended_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    Counter:
      type: object
      properties:
        id:
          type: string
          format: uuid
        host_id:
          type: string
          format: uuid
        rule_id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        packets:
          type: integer
          format: int64
        bytes:
          type: integer
          format: int64
        ts:
          type: string
          format: date-time
    AuditLog:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        actor_type:
          type: string
        actor_id:
          type: string
          format: uuid
          nullable: true
        actor_username:
          type: string
          nullable: true
        action:
          type: string
        object_type:
          type: string
        object_id:
          type: string
          format: uuid
          nullable: true
        before:
          type: object
          nullable: true
        after:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
    LoginRequest:
      type: object
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
    MeResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        username:
          type: string
        email:
          type: string
        role:
          type: string
        tenant_id:
          type: string
          format: uuid
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        username:
          type: string
        email:
          type: string
        status:
          type: string
          enum:
            - active
            - suspended
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
        is_oidc:
          type: boolean
        last_login_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    CreateUserRequest:
      type: object
      required:
        - email
        - password
        - role
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
    PatchUserRequest:
      type: object
      properties:
        status:
          type: string
          enum:
            - active
            - suspended
        password:
          type: string
          format: password
    RoleResponse:
      type: object
      properties:
        user_id:
          type: string
          format: uuid
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
    PutRoleRequest:
      type: object
      required:
        - role
      properties:
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
    APIToken:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        token:
          type: string
          description: Plain token — only present in create response
    CreateAPITokenRequest:
      type: object
      required:
        - name
        - role
      properties:
        name:
          type: string
        role:
          type: string
          enum:
            - admin
            - editor
            - auditor
        expires_at:
          type: string
          format: date-time
          nullable: true
  headers:
    XRequestId:
      description: >
        Chi-generated request id, set on every response (success and error) by
        the request-id middleware. Cross-reference with server logs (QS-05);
        mirrored into the JSON body's `request_id` field on error responses.
      schema:
        type: string
  responses:
    Unauthorized:
      description: Missing or invalid credentials
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Insufficient role
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    BadRequest:
      description: Invalid request
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
paths:
  /healthz:
    get:
      tags:
        - Auth
      summary: Health check
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
  /version:
    get:
      tags:
        - Auth
      summary: Application version
      security: []
      responses:
        '200':
          description: Version info
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: string
  /metrics:
    get:
      tags:
        - Auth
      summary: Prometheus metrics
      security: []
      responses:
        '200':
          description: Prometheus text format metrics
          content:
            text/plain:
              schema:
                type: string
  /auth/login:
    post:
      tags:
        - Auth
      summary: Log in with email and password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login successful; sets `qf_token` and `qf_refresh` cookies
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/logout:
    post:
      tags:
        - Auth
      summary: Clear session cookies
      security: []
      responses:
        '200':
          description: Logged out
  /auth/refresh:
    post:
      tags:
        - Auth
      summary: Refresh access token using refresh cookie
      security: []
      responses:
        '200':
          description: New access token cookie set
        '401':
          description: Invalid or expired refresh token
  /auth/me:
    get:
      tags:
        - Auth
      summary: Get current user identity
      responses:
        '200':
          description: Current user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/oidc/enabled:
    get:
      tags:
        - Auth
      summary: Check if OIDC login is enabled
      security: []
      responses:
        '200':
          description: OIDC status
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled:
                    type: boolean
  /auth/oidc/login:
    get:
      tags:
        - Auth
      summary: Initiate OIDC login flow (redirect to provider)
      security: []
      responses:
        '302':
          description: Redirect to OIDC provider
  /auth/oidc/callback:
    get:
      tags:
        - Auth
      summary: OIDC callback — exchange code for tokens
      security: []
      parameters:
        - name: code
          in: query
          schema:
            type: string
        - name: state
          in: query
          schema:
            type: string
      responses:
        '302':
          description: Redirect to /app after successful login
        '400':
          description: Invalid state or code
  /hosts:
    get:
      tags:
        - Hosts
      summary: List hosts (cursor-paginated)
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ChangedSince'
        - $ref: '#/components/parameters/Limit'
        - name: before
          in: query
          description: >-
            Cursor — return hosts with created_at strictly before this RFC3339
            timestamp (newest first).
          schema:
            type: string
            format: date-time
        - name: cap
          in: query
          description: >
            VIZ-02 rule-cap drill-down filter — return only hosts whose resolved
            rule-cap tier equals this value. The resolved cap is
            metrics.rules_cap, falling back to capabilities.ruleCap; a host with
            no resolvable used+cap is excluded when this filter is set.
          schema:
            type: string
            enum:
              - '32'
              - '2048'
              - other
        - name: rules_min
          in: query
          description: >-
            VIZ-02 drill-down filter — only hosts whose metrics.rules_used is ≥
            this value (inclusive).
          schema:
            type: integer
        - name: rules_max
          in: query
          description: >-
            VIZ-02 drill-down filter — only hosts whose metrics.rules_used is ≤
            this value (inclusive).
          schema:
            type: integer
      responses:
        '200':
          description: List of hosts
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Host'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /hosts/rulecap-summary:
    get:
      tags:
        - Hosts
      summary: Rule-cap cohort distribution (tenant-scoped)
      description: >
        VIZ-02 fleet rule-cap pressure, aggregated server-side so the dashboard
        widget does not fetch the whole fleet. Hosts are grouped by resolved
        rule-cap tier (2048 bpf_loop / other), then bucketed into utilization
        cohorts (over/saturated/near/mid/low). Scoped to the caller's tenant.
      parameters:
        - $ref: '#/components/parameters/TenantID'
      responses:
        '200':
          description: Cohort distribution
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RuleCapSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags:
        - Hosts
      summary: Create a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateHostRequest'
      responses:
        '201':
          description: Host created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Host'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /hosts/{id}:
    get:
      tags:
        - Hosts
      summary: Get a host by ID
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Host
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Host'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Hosts
      summary: Update host labels, status, or flow_events_enabled
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchHostRequest'
      responses:
        '200':
          description: Updated host
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Host'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Hosts
      summary: Delete a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Deleted
        '404':
          $ref: '#/components/responses/NotFound'
  /hosts/{id}/groups:
    get:
      tags:
        - Hosts
      summary: List host groups the host belongs to
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Host groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/HostGroup'
        '404':
          $ref: '#/components/responses/NotFound'
  /hosts/{id}/ruleset:
    get:
      tags:
        - Hosts
      summary: Get the effective compiled ruleset for a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Effective ruleset
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EffectiveRuleset'
        '404':
          $ref: '#/components/responses/NotFound'
  /hosts/{id}/reapply:
    post:
      tags:
        - Hosts
      summary: Re-apply the current policy bundle to a host
      description: >
        Bumps desired_generation and re-pushes the host's current bundle,
        overwriting any manual datapath drift. Requires admin or editor.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '202':
          description: Re-apply requested
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /hosts/{id}/system-events:
    get:
      tags:
        - Events
      summary: List system events for a host (datapath_drift, host_unreachable, …)
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: System events
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SystemEvent'
  /hosts/{id}/events:
    get:
      tags:
        - Events
      summary: List log events for a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - $ref: '#/components/parameters/StartTime'
        - $ref: '#/components/parameters/EndTime'
        - $ref: '#/components/parameters/Limit'
        - name: rule_id
          in: query
          schema:
            type: string
            format: uuid
        - name: action
          in: query
          schema:
            type: string
            enum:
              - allow
              - deny
              - log
      responses:
        '200':
          description: Log events
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LogEvent'
  /hosts/{id}/events/stream:
    get:
      tags:
        - Events
      summary: SSE live tail of log events
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: Last-Event-ID
          in: header
          description: Resume from this RFC3339Nano timestamp
          schema:
            type: string
      responses:
        '200':
          description: Server-sent event stream (text/event-stream)
          content:
            text/event-stream:
              schema:
                type: string
  /hosts/{id}/flows:
    get:
      tags:
        - Events
      summary: List flow events for a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - $ref: '#/components/parameters/StartTime'
        - $ref: '#/components/parameters/EndTime'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Flow events
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/FlowEvent'
  /hosts/{id}/counters:
    get:
      tags:
        - Events
      summary: List counter snapshots for a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - $ref: '#/components/parameters/StartTime'
        - $ref: '#/components/parameters/EndTime'
        - $ref: '#/components/parameters/Limit'
        - name: rule_id
          in: query
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Counter snapshots
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Counter'
  /hosts/{id}/counters/latest:
    get:
      tags:
        - Events
      summary: Latest counter snapshot per rule for a host
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Latest counters
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Counter'
  /events/aggregate:
    get:
      tags:
        - Events
      summary: Verdicts log-event aggregate (top denying rules/hosts, deny timeline)
      description: >
        Tenant-scoped rollup over the partitioned log_events table. group_by
        selects one closed preset (by-rule / by-host / timeline) — the string
        only SELECTS a static named query and is NEVER interpolated into SQL; an
        unknown non-empty value → 400. by-rule and timeline are host-scoped
        (host_id required); by-host aggregates top denying hosts across the
        whole tenant. The [from,to) window defaults to 24h and is capped 7d
        server-side so the scan stays partition-pruned. Read tier.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: group_by
          in: query
          description: Aggregate preset; omitted = by-rule. Unknown value → 400.
          schema:
            type: string
            enum:
              - by-rule
              - by-host
              - timeline
        - name: host_id
          in: query
          description: Required for by-rule/timeline; ignored for by-host.
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: Inclusive lower bound on created_at (RFC3339); default now-24h.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Exclusive upper bound on created_at (RFC3339); default now.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          description: >-
            Max rows for by-rule/by-host (default 500, capped 10000). Ignored by
            timeline.
          schema:
            type: integer
      responses:
        '200':
          description: Verdicts aggregate — echoes the effective preset + window + rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  group_by:
                    type: string
                  window:
                    type: object
                    properties:
                      from:
                        type: string
                        format: date-time
                      to:
                        type: string
                        format: date-time
                  rows:
                    type: array
                    items:
                      type: object
                      description: >
                        Only the keys the chosen preset populates are set.
                        by-rule → rule_id/rule_name; by-host → host_id/hostname;
                        timeline → bucket.
                      properties:
                        rule_id:
                          type: string
                          format: uuid
                        rule_name:
                          type: string
                        host_id:
                          type: string
                          format: uuid
                        hostname:
                          type: string
                        bucket:
                          type: string
                          format: date-time
                        total:
                          type: integer
                          format: int64
                        deny_count:
                          type: integer
                          format: int64
        '400':
          $ref: '#/components/responses/BadRequest'
  /host-groups:
    get:
      tags:
        - HostGroups
      summary: List host groups
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ChangedSince'
      responses:
        '200':
          description: Host groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/HostGroup'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags:
        - HostGroups
      summary: Create a host group
      parameters:
        - $ref: '#/components/parameters/TenantID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateHostGroupRequest'
      responses:
        '201':
          description: Host group created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostGroup'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /host-groups/{id}:
    get:
      tags:
        - HostGroups
      summary: Get a host group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Host group
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostGroup'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - HostGroups
      summary: Update a host group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateHostGroupRequest'
      responses:
        '200':
          description: Updated host group
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostGroup'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - HostGroups
      summary: Delete a host group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Deleted
        '404':
          $ref: '#/components/responses/NotFound'
  /host-groups/{id}/members:
    get:
      tags:
        - HostGroups
      summary: List hosts in a group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Member hosts
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Host'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - HostGroups
      summary: Add a host to a group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostGroupMemberRequest'
      responses:
        '204':
          description: Host added
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
  /host-groups/{id}/members/{host_id}:
    delete:
      tags:
        - HostGroups
      summary: Remove a host from a group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: host_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Host removed
        '404':
          $ref: '#/components/responses/NotFound'
  /versions:
    get:
      tags:
        - Versions
      summary: List applied config versions across all entity types (VERLIVE-03)
      description: >
        Tenant-scoped cross-entity browse of applied config_versions (policy,
        objectgroup, host_group, host_labels). Filter by optional entity type +
        created_at range; newest-first, LIMIT-capped. The display `name` is
        resolved from the version's content snapshot as-of that version (no
        JOIN). Read tier.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: type
          in: query
          description: Filter by entity type; omitted = all types. Unknown value → 400.
          schema:
            type: string
            enum:
              - policy
              - objectgroup
              - host_group
              - host_labels
        - name: from
          in: query
          description: Inclusive lower bound on created_at (RFC3339).
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Exclusive upper bound on created_at (RFC3339).
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          description: Max rows (default 200, capped 1000).
          schema:
            type: integer
      responses:
        '200':
          description: Cross-entity applied version list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    entity_type:
                      type: string
                    entity_id:
                      type: string
                      format: uuid
                    name:
                      type: string
                      description: Display name resolved from the content snapshot
                    version:
                      type: integer
                    status:
                      type: string
                    content_version:
                      type: integer
                    is_deletion:
                      type: boolean
                    content:
                      type: object
                    created_by:
                      type: string
                    created_at:
                      type: string
                      format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
  /versions/impact:
    get:
      tags:
        - Versions
      summary: Preview the COMBINED impact of a set of applied versions (B-11)
      description: >
        Read-only combined impact preview for a SET of selected applied
        versions, in a single round-trip. Runs the per-host match-aware blast
        once per version and returns ONE observed-traffic aggregate over the
        UNION of their before-side rule ids over a bounded window (default 24h,
        capped 7d server-side, partition-pruned) — so a rule observed by two
        selected versions over the shared window is counted exactly once (no
        cross-version double-count) and N HTTP round-trips collapse to one.
        Preview-only — stages/applies/signs nothing. Repeated `ids` param (NOT
        comma-joined). A cross-tenant/missing/malformed id is silently OMITTED
        from the batch (partial results are valid), never a 404. An absent `ids`
        param is a 400.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: ids
          in: query
          required: true
          description: >
            Applied-version ids to combine — repeated once per version
            (`?ids=A&ids=B`), NOT comma-joined. Absent → 400.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: from
          in: query
          description: Traffic window start (RFC3339); defaulted/capped server-side.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Traffic window end (RFC3339); defaults to now.
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: >-
            Combined impact (per-version blasts + one union traffic aggregate +
            window used)
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions:
                    type: array
                    description: >-
                      Per-version blast (version identity + affected-host set +
                      per-host match-aware diff), no per-version traffic.
                    items:
                      type: object
                      properties:
                        version_id:
                          type: string
                          format: uuid
                        entity_type:
                          type: string
                        entity_id:
                          type: string
                          format: uuid
                        version:
                          type: integer
                        blast:
                          type: object
                          description: >-
                            Affected-host set + per-host match-aware diff
                            (per-host detail for policy, object_group and
                            host_group; host_labels stays count-only)
                  traffic:
                    type: array
                    description: >-
                      ONE observed-traffic aggregate over the UNION of every
                      version's before-side rule ids (deduped — no cross-version
                      double-count).
                    items:
                      type: object
                      properties:
                        rule_id:
                          type: string
                          format: uuid
                        decisions:
                          type: integer
                  window:
                    type: object
                    properties:
                      from:
                        type: string
                        format: date-time
                      to:
                        type: string
                        format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
        '503':
          description: Revisioning disabled
  /versions/{versionID}/impact:
    get:
      tags:
        - Versions
      summary: Preview the impact of an applied version (VERLIVE-04/05)
      description: >
        Read-only impact preview for a selected applied version. Computes the
        per-host match-aware before/after ruleset diff (before = current
        effective ruleset, after = the version's snapshot) and attaches observed
        log_events traffic (decisions) for the affected BEFORE-side rule ids
        over a bounded window (default 24h, capped 7d server-side,
        partition-pruned). Preview-only — it stages/applies/signs nothing. A
        cross-tenant/missing id returns 404.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: versionID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: Traffic window start (RFC3339); defaulted/capped server-side.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Traffic window end (RFC3339); defaults to now.
          schema:
            type: string
            format: date-time
        - name: breakdown_by
          in: query
          description: >
            B-09 traffic breakdown preset. Whitelisted; default `port` ((rule,
            protocol, dst_port)). `peer` groups by (rule, src_ip); `port_peer`
            by (rule, protocol, dst_port, src_ip). An unknown value is a 400.
          schema:
            type: string
            enum:
              - port
              - peer
              - port_peer
            default: port
      responses:
        '200':
          description: >-
            Impact preview (blast diff + per-rule traffic + per-port breakdown +
            window used)
          content:
            application/json:
              schema:
                type: object
                properties:
                  version_id:
                    type: string
                    format: uuid
                  entity_type:
                    type: string
                  entity_id:
                    type: string
                    format: uuid
                  version:
                    type: integer
                  blast:
                    type: object
                    description: >-
                      Affected-host set + per-host match-aware diff (policy only
                      for per-host detail)
                  traffic:
                    type: array
                    items:
                      type: object
                      properties:
                        rule_id:
                          type: string
                          format: uuid
                        decisions:
                          type: integer
                  breakdown:
                    type: array
                    description: >
                      B-09 traffic breakdown of the observed traffic over the
                      affected rules — one row per bucket, top-N by decisions.
                      The bucketing follows the breakdown_by preset: `port` →
                      (rule, protocol, dst_port); `peer` → (rule, src_ip);
                      `port_peer` → (rule, protocol, dst_port, src_ip).
                      approx_bytes is a SUM(packet_size) APPROXIMATION, not
                      enforced flow bytes. Additive/omitempty.
                    items:
                      type: object
                      properties:
                        rule_id:
                          type: string
                          format: uuid
                        protocol:
                          type: integer
                          description: Numeric IP protocol (6=tcp
                          17=udp: null
                          1=icmp: null
                          58=icmpv6); zero on the peer preset: null
                        dst_port:
                          type: integer
                          description: Zero on the peer preset
                        peer_ip:
                          type: string
                          description: Remote src_ip; present on the peer/port_peer presets
                          omitted on the port preset: null
                        decisions:
                          type: integer
                        approx_bytes:
                          type: integer
                          format: int64
                          description: SUM(packet_size) approximation
                        packets:
                          type: integer
                          format: int64
                  window:
                    type: object
                    properties:
                      from:
                        type: string
                        format: date-time
                      to:
                        type: string
                        format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: Revisioning disabled
  /versions/compare:
    get:
      tags:
        - Versions
      summary: Compare two versions of the same entity (B-10, past → future diff)
      description: >
        Read-only TWO-POINT comparison of versions A (past) and B (future) of
        the SAME entity. Unlike /versions/{id}/impact (which always diffs
        against the current live ruleset), compare diffs two arbitrary snapshots
        directly. For a policy it returns the OG-AWARE spec-level field diff
        (added/removed/changed with per-field before→after on resolved src/dst
        CIDRs, ports, protocol, tcp_flags, state, action) — object-group
        references are expanded so a rule differing only in a referenced group's
        membership classifies `changed`, not "No changes". For
        object_group/host_group/host_labels it returns the member set-diff
        (members_added/members_removed) with an empty diff. Both snapshots are
        upgraded via ToCurrent before diffing so an older content_version on A
        is not mis-diffed against a newer B. Side semantics: added = present in
        B not A; removed = in A not B; compare(A,B) and compare(B,A) are mirror
        images. Traffic (decisions) is attached for the A-SIDE rule ids over a
        bounded window and is empty when snapshot A carries no stable rule ids.
        Preview-only — it stages/applies/signs nothing. A and B MUST belong to
        the same entity (else 400); a missing/cross-tenant version returns 404.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: a
          in: query
          required: true
          description: Version id of side A (the "past" snapshot).
          schema:
            type: string
            format: uuid
        - name: b
          in: query
          required: true
          description: Version id of side B (the "future"/other snapshot).
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: Traffic window start (RFC3339); defaulted/capped server-side.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Traffic window end (RFC3339); defaults to now.
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: >-
            Two-point compare (A/B refs + spec/member diff + A-side traffic +
            window used)
          content:
            application/json:
              schema:
                type: object
                properties:
                  entity_type:
                    type: string
                  entity_id:
                    type: string
                    format: uuid
                  a:
                    type: object
                    description: Reference to side A (past).
                    properties:
                      version_id:
                        type: string
                        format: uuid
                      version:
                        type: integer
                      created_at:
                        type: string
                        format: date-time
                      name:
                        type: string
                  b:
                    type: object
                    description: Reference to side B (future).
                    properties:
                      version_id:
                        type: string
                        format: uuid
                      version:
                        type: integer
                      created_at:
                        type: string
                        format: date-time
                      name:
                        type: string
                  diff:
                    type: object
                    description: >
                      OG-aware spec-level A→B diff (policy only; empty for set
                      entities). Same HostDiff shape as the impact blast
                      per-host diff.
                  members_added:
                    type: array
                    description: >-
                      Members present in B but not A
                      (object_group/host_group/host_labels).
                    items:
                      type: string
                  members_removed:
                    type: array
                    description: >-
                      Members present in A but not B
                      (object_group/host_group/host_labels).
                    items:
                      type: string
                  traffic:
                    type: array
                    items:
                      type: object
                      properties:
                        rule_id:
                          type: string
                          format: uuid
                        decisions:
                          type: integer
                  window:
                    type: object
                    properties:
                      from:
                        type: string
                        format: date-time
                      to:
                        type: string
                        format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: Revisioning disabled
  /policies:
    get:
      tags:
        - Policies
      summary: List policies
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ChangedSince'
      responses:
        '200':
          description: Policies
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Policy'
    post:
      tags:
        - Policies
      summary: Create a policy
      parameters:
        - $ref: '#/components/parameters/TenantID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePolicyRequest'
      responses:
        '201':
          description: Policy created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
  /policies/graph:
    get:
      tags:
        - Policies
      summary: Policy coverage graph (bucket-aggregated)
      description: >
        Returns a bucket-aggregated view of which hosts each policy currently
        reaches, so the node count is bounded by policy×bucket instead of
        policy×host. Every edge carries a matched_hosts coverage count and every
        bucket a total_hosts denominator, both computed over the same
        active-host set (revoked and enrolling hosts are excluded), so
        matched_hosts is always less than or equal to total_hosts. The group_by
        parameter selects the bucket dimension.
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - name: group_by
          in: query
          required: false
          description: >
            Bucket dimension. `hostgroup` (default) buckets by host group — a
            host that belongs to several groups counts in each of them, and
            active hosts in no group fall into a synthetic `(ungrouped)` bucket.
            `label:<key>` buckets by the value of the given effective-label key,
            with hosts missing the key in an `(unset)` bucket. `none` returns
            one bucket per host (per-host shape). Unrecognised values fall back
            to `hostgroup`.
          schema:
            type: string
            default: hostgroup
            example: label:env
      responses:
        '200':
          description: Bucket-aggregated policy coverage graph
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PolicyGraph'
  /policies/coverage:
    get:
      tags:
        - Policies
      summary: Per-policy host reach summary (list columns)
      description: >
        Returns one row per policy with its active-host reach split by HOW a
        host is targeted, plus its rule count — the data behind the
        policies-list columns and the derived active/inert status.
        `direct_hosts` counts hosts matched by a direct block (a pinned host or
        a bare label/expression block); `group_hosts` counts hosts matched by a
        host-group block; `total_hosts` is the distinct hosts matched either
        way. A host can match both ways, so `direct_hosts + group_hosts` can
        exceed `total_hosts` — `total_hosts` is the true blast radius. Counts
        are over the active-host set (revoked and enrolling hosts excluded).
        `rules_count` is the policy's live rule count; a policy is considered
        inert when it reaches no host or carries no rule.
      parameters:
        - $ref: '#/components/parameters/TenantID'
      responses:
        '200':
          description: Per-policy coverage summary
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PolicyCoverage'
  /policies/{id}:
    get:
      tags:
        - Policies
      summary: Get a policy
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Policy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Policies
      summary: Update a policy (creates a version snapshot)
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePolicyRequest'
      responses:
        '200':
          description: Updated policy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
    delete:
      tags:
        - Policies
      summary: Delete a policy
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Deleted
        '404':
          $ref: '#/components/responses/NotFound'
  /policies/{id}/rules:
    get:
      tags:
        - Policies
      summary: List rules for a policy
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Rules
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Rule'
    post:
      tags:
        - Policies
      summary: Create a rule
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRuleRequest'
      responses:
        '201':
          description: Rule created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
  /policies/{id}/rules/{ruleID}:
    get:
      tags:
        - Policies
      summary: Get a rule
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: ruleID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Rule
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Policies
      summary: Update a rule
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: ruleID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRuleRequest'
      responses:
        '200':
          description: Updated rule
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
    delete:
      tags:
        - Policies
      summary: Delete a rule
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: ruleID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
  /policies/{id}/preview:
    post:
      tags:
        - Policies
      summary: Dry-run preview — compute host impact without applying
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PreviewRequest'
      responses:
        '200':
          description: Preview result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PreviewResult'
  /policies/{id}/versions:
    get:
      tags:
        - Policies
      summary: List version snapshots for a policy
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Version history
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PolicyVersion'
  /policies/{id}/drafts/{draftId}/blast:
    post:
      tags:
        - Policies
      summary: Write-free blast preview of a staged draft (B-12)
      description: >
        Computes the affected-host impact of a staged draft WITHOUT writing,
        enriched with the observed traffic (decisions) for the affected
        before-side rules over a bounded window. Read tier (writes nothing) —
        POST because it echoes the projected draft content. The identical route
        is mounted on object_groups and host_groups (shared version registrar).
        Optional from/to bound the traffic window (default 24h, capped 7d
        server-side, partition-pruned).
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: draftId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: Traffic window start (RFC3339); defaulted/capped server-side.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Traffic window end (RFC3339); defaults to now.
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Blast preview (per-host diff + per-rule traffic + window used)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlastImpact'
        '404':
          $ref: '#/components/responses/NotFound'
  /policies/{id}/versions/{v}/revert:
    post:
      tags:
        - Policies
      summary: Revert policy to a previous version
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
        - name: v
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Reverted policy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '404':
          $ref: '#/components/responses/NotFound'
  /objectgroups:
    get:
      tags:
        - ObjectGroups
      summary: List object groups
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ChangedSince'
        - name: type
          in: query
          schema:
            type: string
            enum:
              - ipset
              - portset
              - hostset
      responses:
        '200':
          description: Object groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ObjectGroup'
    post:
      tags:
        - ObjectGroups
      summary: Create an object group
      parameters:
        - $ref: '#/components/parameters/TenantID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateObjectGroupRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObjectGroup'
  /objectgroups/{id}:
    get:
      tags:
        - ObjectGroups
      summary: Get an object group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Object group
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObjectGroup'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - ObjectGroups
      summary: Update object group spec
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateObjectGroupRequest'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObjectGroup'
    delete:
      tags:
        - ObjectGroups
      summary: Delete an object group
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Deleted
  /tokens:
    get:
      tags:
        - Tokens
      summary: List enrollment tokens
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ChangedSince'
      responses:
        '200':
          description: Tokens
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Token'
    post:
      tags:
        - Tokens
      summary: Create an enrollment token
      parameters:
        - $ref: '#/components/parameters/TenantID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTokenRequest'
      responses:
        '201':
          description: Token created; plain token in `token` field (shown once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Token'
  /tokens/{id}:
    delete:
      tags:
        - Tokens
      summary: Revoke an enrollment token
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Revoked
  /audit-log:
    get:
      tags:
        - AuditLog
      summary: List audit log entries
      parameters:
        - $ref: '#/components/parameters/TenantID'
        - $ref: '#/components/parameters/StartTime'
        - $ref: '#/components/parameters/EndTime'
        - $ref: '#/components/parameters/Limit'
        - name: actor_type
          in: query
          description: >-
            Actor kind(s) to include. Repeatable
            (?actor_type=user&actor_type=agent) — values are unioned server-side
            (OR). A single value stays valid.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - user
                - api_token
                - system
                - agent
        - name: actor_id
          in: query
          description: >-
            Actor UUID(s) to include. Repeatable (?actor_id=a&actor_id=b) —
            values are unioned server-side (OR). A single value stays valid
            (backward-compatible).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: object_type
          in: query
          schema:
            type: string
        - name: object_id
          in: query
          schema:
            type: string
            format: uuid
        - name: q
          in: query
          description: >-
            Free-text filter — case-insensitive substring match over the action
            string.
          schema:
            type: string
      responses:
        '200':
          description: Audit log entries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AuditLog'
  /users:
    get:
      tags:
        - Users
      summary: List users
      parameters:
        - $ref: '#/components/parameters/ChangedSince'
      responses:
        '200':
          description: Users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
    post:
      tags:
        - Users
      summary: Create a user (Admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          $ref: '#/components/responses/Forbidden'
  /users/{id}:
    get:
      tags:
        - Users
      summary: Get a user
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: User
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Users
      summary: Update user status or password (Admin only)
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchUserRequest'
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          $ref: '#/components/responses/Forbidden'
    delete:
      tags:
        - Users
      summary: Delete a user (Admin only)
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Deleted
        '403':
          $ref: '#/components/responses/Forbidden'
  /users/{id}/roles:
    get:
      tags:
        - Users
      summary: Get user role
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '200':
          description: Role
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoleResponse'
    put:
      tags:
        - Users
      summary: Set user role (Admin only)
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PutRoleRequest'
      responses:
        '200':
          description: Updated role
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoleResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api-tokens:
    get:
      tags:
        - APITokens
      summary: List API tokens
      parameters:
        - $ref: '#/components/parameters/ChangedSince'
      responses:
        '200':
          description: API tokens
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/APIToken'
    post:
      tags:
        - APITokens
      summary: Create an API token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPITokenRequest'
      responses:
        '201':
          description: Token created; plain token in `token` field (shown once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIToken'
  /api-tokens/{id}:
    delete:
      tags:
        - APITokens
      summary: Revoke an API token
      parameters:
        - $ref: '#/components/parameters/ResourceID'
      responses:
        '204':
          description: Revoked
  /openapi.yaml:
    get:
      tags:
        - Auth
      summary: OpenAPI 3.0 spec (this file)
      security: []
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
  /docs:
    get:
      tags:
        - Auth
      summary: Swagger UI
      security: []
      responses:
        '200':
          description: Interactive API documentation
