openapi: 3.1.0
info:
  title: certctl API
  description: |
    Certificate lifecycle management platform API. Manages certificates, issuers,
    deployment targets, agents, jobs, policies, profiles, teams, owners, agent groups,
    audit events, notifications, and observability metrics.

    All endpoints under `/api/v1/` require authentication by default (configurable via
    `CERTCTL_AUTH_TYPE`). Use `Bearer {api_key}` in the Authorization header.

    Paginated list endpoints accept `page` (default 1) and `per_page` (default 50, max 500)
    query parameters and return a standard envelope with `data`, `total`, `page`, and `per_page`.
  version: 2.0.0
  license:
    name: BSL 1.1
    url: https://github.com/shankar0123/certctl/blob/master/LICENSE

servers:
  - url: http://localhost:8080
    description: Local development
  - url: http://localhost:8443
    description: Docker Compose demo

security:
  - bearerAuth: []

tags:
  - name: Certificates
    description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation
  - name: CRL & OCSP
    description: Certificate revocation list and OCSP responder
  - name: Issuers
    description: CA issuer connector management (Local CA, ACME, step-ca)
  - name: Targets
    description: Deployment target management (NGINX, Apache, HAProxy, F5, IIS)
  - name: Agents
    description: Agent registration, heartbeat, CSR submission, work polling
  - name: Jobs
    description: Job queue — issuance, renewal, deployment, validation
  - name: Policies
    description: Policy rules and violation tracking
  - name: Profiles
    description: Certificate enrollment profiles with crypto constraints
  - name: Teams
    description: Team management for ownership grouping
  - name: Owners
    description: Certificate owner management with email routing
  - name: Agent Groups
    description: Dynamic agent grouping by OS, architecture, IP CIDR, version
  - name: Audit
    description: Immutable audit trail
  - name: Notifications
    description: Notification events (expiration, renewal, deployment, revocation)
  - name: Stats
    description: Dashboard statistics and aggregations
  - name: Metrics
    description: System metrics (gauges, counters, uptime)
  - name: Health
    description: Health and readiness probes, auth info
  - name: Discovery
    description: Certificate discovery — filesystem scanning by agents and network TLS probing
  - name: Network Scan
    description: Network scan target management for active TLS certificate discovery

paths:
  # ─── Health & Auth ───────────────────────────────────────────────────
  /health:
    get:
      tags: [Health]
      summary: Health check
      security: []
      operationId: getHealth
      responses:
        "200":
          description: Server is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: healthy

  /ready:
    get:
      tags: [Health]
      summary: Readiness check
      security: []
      operationId: getReady
      responses:
        "200":
          description: Server is ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ready

  /api/v1/auth/info:
    get:
      tags: [Health]
      summary: Auth configuration info
      description: Returns auth mode. Served without auth so GUI can detect auth requirements before login.
      security: []
      operationId: getAuthInfo
      responses:
        "200":
          description: Auth configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_type:
                    type: string
                    enum: [api-key, jwt, none]
                  required:
                    type: boolean

  /api/v1/auth/check:
    get:
      tags: [Health]
      summary: Validate credentials
      description: Returns 200 if auth credentials are valid, 401 otherwise.
      operationId: checkAuth
      responses:
        "200":
          description: Authenticated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: authenticated
        "401":
          description: Unauthorized

  # ─── Certificates ────────────────────────────────────────────────────
  /api/v1/certificates:
    get:
      tags: [Certificates]
      summary: List certificates
      operationId: listCertificates
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/CertificateStatus"
        - name: environment
          in: query
          schema:
            type: string
        - name: owner_id
          in: query
          schema:
            type: string
        - name: team_id
          in: query
          schema:
            type: string
        - name: issuer_id
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Paginated list of certificates
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/ManagedCertificate"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Certificates]
      summary: Create certificate
      operationId: createCertificate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ManagedCertificate"
      responses:
        "201":
          description: Certificate created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ManagedCertificate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}:
    get:
      tags: [Certificates]
      summary: Get certificate
      operationId: getCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Certificate details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ManagedCertificate"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Certificates]
      summary: Update certificate
      operationId: updateCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ManagedCertificate"
      responses:
        "200":
          description: Certificate updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ManagedCertificate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Certificates]
      summary: Archive certificate
      operationId: archiveCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Certificate archived
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}/versions:
    get:
      tags: [Certificates]
      summary: List certificate versions
      operationId: listCertificateVersions
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of certificate versions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/CertificateVersion"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}/renew:
    post:
      tags: [Certificates]
      summary: Trigger certificate renewal
      operationId: triggerRenewal
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "202":
          description: Renewal triggered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}/deploy:
    post:
      tags: [Certificates]
      summary: Trigger certificate deployment
      operationId: triggerDeployment
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                target_id:
                  type: string
                  description: Optional specific target ID
      responses:
        "202":
          description: Deployment triggered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}/revoke:
    post:
      tags: [Certificates]
      summary: Revoke certificate
      description: |
        Revokes a certificate with an optional RFC 5280 reason code. Records revocation in
        cert inventory, audit log, and certificate_revocations table. Best-effort issuer notification.
      operationId: revokeCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  $ref: "#/components/schemas/RevocationReason"
      responses:
        "200":
          description: Certificate revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── CRL & OCSP ─────────────────────────────────────────────────────
  /api/v1/crl:
    get:
      tags: [CRL & OCSP]
      summary: Get JSON CRL
      description: Returns all revoked certificates in JSON format.
      operationId: getCRL
      responses:
        "200":
          description: JSON CRL
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: integer
                    example: 1
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        serial_number:
                          type: string
                        revocation_date:
                          type: string
                          format: date-time
                        revocation_reason:
                          type: string
                  total:
                    type: integer
                  generated_at:
                    type: string
                    format: date-time
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/crl/{issuer_id}:
    get:
      tags: [CRL & OCSP]
      summary: Get DER-encoded X.509 CRL
      description: Returns a proper DER-encoded CRL signed by the issuing CA. 24-hour validity.
      operationId: getDERCRL
      parameters:
        - name: issuer_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: DER-encoded CRL
          content:
            application/pkix-crl:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
        "501":
          description: Issuer does not support CRL generation

  /api/v1/ocsp/{issuer_id}/{serial}:
    get:
      tags: [CRL & OCSP]
      summary: OCSP responder
      description: Returns signed OCSP response (good/revoked/unknown) for the given serial number.
      operationId: handleOCSP
      parameters:
        - name: issuer_id
          in: path
          required: true
          schema:
            type: string
        - name: serial
          in: path
          required: true
          description: Hex-encoded certificate serial number
          schema:
            type: string
      responses:
        "200":
          description: OCSP response
          content:
            application/ocsp-response:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
        "501":
          description: Issuer does not support OCSP

  # ─── Issuers ─────────────────────────────────────────────────────────
  /api/v1/issuers:
    get:
      tags: [Issuers]
      summary: List issuers
      operationId: listIssuers
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of issuers
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Issuer"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Issuers]
      summary: Create issuer
      operationId: createIssuer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Issuer"
      responses:
        "201":
          description: Issuer created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Issuer"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/issuers/{id}:
    get:
      tags: [Issuers]
      summary: Get issuer
      operationId: getIssuer
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Issuer details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Issuer"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Issuers]
      summary: Update issuer
      operationId: updateIssuer
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Issuer"
      responses:
        "200":
          description: Issuer updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Issuer"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Issuers]
      summary: Delete issuer
      operationId: deleteIssuer
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Issuer deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/issuers/{id}/test:
    post:
      tags: [Issuers]
      summary: Test issuer connection
      operationId: testIssuerConnection
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Connection successful
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Targets ─────────────────────────────────────────────────────────
  /api/v1/targets:
    get:
      tags: [Targets]
      summary: List targets
      operationId: listTargets
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of targets
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/DeploymentTarget"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Targets]
      summary: Create target
      operationId: createTarget
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeploymentTarget"
      responses:
        "201":
          description: Target created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeploymentTarget"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/targets/{id}:
    get:
      tags: [Targets]
      summary: Get target
      operationId: getTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Target details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeploymentTarget"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Targets]
      summary: Update target
      operationId: updateTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeploymentTarget"
      responses:
        "200":
          description: Target updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeploymentTarget"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Targets]
      summary: Delete target
      operationId: deleteTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Target deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Agents ──────────────────────────────────────────────────────────
  /api/v1/agents:
    get:
      tags: [Agents]
      summary: List agents
      operationId: listAgents
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of agents
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Agent"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Agents]
      summary: Register agent
      operationId: registerAgent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Agent"
      responses:
        "201":
          description: Agent registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}:
    get:
      tags: [Agents]
      summary: Get agent
      operationId: getAgent
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Agent details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}/heartbeat:
    post:
      tags: [Agents]
      summary: Agent heartbeat
      description: Reports agent liveness and metadata (OS, architecture, IP, version).
      operationId: agentHeartbeat
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: string
                hostname:
                  type: string
                os:
                  type: string
                architecture:
                  type: string
                ip_address:
                  type: string
      responses:
        "200":
          description: Heartbeat recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}/csr:
    post:
      tags: [Agents]
      summary: Submit CSR
      description: Agent submits a PEM-encoded CSR for signing. Used in agent keygen mode.
      operationId: agentSubmitCSR
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [csr_pem]
              properties:
                csr_pem:
                  type: string
                  description: PEM-encoded certificate signing request
                certificate_id:
                  type: string
      responses:
        "202":
          description: CSR accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}/certificates/{cert_id}:
    get:
      tags: [Agents]
      summary: Pick up signed certificate
      description: Agent retrieves the signed certificate PEM after CSR signing completes.
      operationId: agentPickupCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: cert_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Certificate PEM
          content:
            application/json:
              schema:
                type: object
                properties:
                  certificate_pem:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}/work:
    get:
      tags: [Agents]
      summary: Get pending work
      description: Returns pending deployment and AwaitingCSR jobs for the agent.
      operationId: agentGetWork
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Work items
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items:
                      $ref: "#/components/schemas/WorkItem"
                  count:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/{id}/jobs/{job_id}/status:
    post:
      tags: [Agents]
      summary: Report job status
      description: Agent reports completion or failure of an assigned job.
      operationId: agentReportJobStatus
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: job_id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                error:
                  type: string
      responses:
        "200":
          description: Status updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Jobs ────────────────────────────────────────────────────────────
  /api/v1/jobs:
    get:
      tags: [Jobs]
      summary: List jobs
      operationId: listJobs
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/JobStatus"
        - name: type
          in: query
          schema:
            $ref: "#/components/schemas/JobType"
      responses:
        "200":
          description: Paginated list of jobs
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Job"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{id}:
    get:
      tags: [Jobs]
      summary: Get job
      operationId: getJob
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Job details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{id}/cancel:
    post:
      tags: [Jobs]
      summary: Cancel job
      operationId: cancelJob
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Job cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{id}/approve:
    post:
      tags: [Jobs]
      summary: Approve job
      description: Approves a job in AwaitingApproval state.
      operationId: approveJob
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Job approved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{id}/reject:
    post:
      tags: [Jobs]
      summary: Reject job
      description: Rejects a job in AwaitingApproval state with an optional reason.
      operationId: rejectJob
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        "200":
          description: Job rejected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Policies ────────────────────────────────────────────────────────
  /api/v1/policies:
    get:
      tags: [Policies]
      summary: List policies
      operationId: listPolicies
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of policies
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/PolicyRule"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Policies]
      summary: Create policy
      operationId: createPolicy
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyRule"
      responses:
        "201":
          description: Policy created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/policies/{id}:
    get:
      tags: [Policies]
      summary: Get policy
      operationId: getPolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Policy details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Policies]
      summary: Update policy
      operationId: updatePolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyRule"
      responses:
        "200":
          description: Policy updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Policies]
      summary: Delete policy
      operationId: deletePolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Policy deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/policies/{id}/violations:
    get:
      tags: [Policies]
      summary: List policy violations
      operationId: listPolicyViolations
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of violations
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/PolicyViolation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Profiles ────────────────────────────────────────────────────────
  /api/v1/profiles:
    get:
      tags: [Profiles]
      summary: List profiles
      operationId: listProfiles
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of profiles
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/CertificateProfile"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Profiles]
      summary: Create profile
      operationId: createProfile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CertificateProfile"
      responses:
        "201":
          description: Profile created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificateProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/profiles/{id}:
    get:
      tags: [Profiles]
      summary: Get profile
      operationId: getProfile
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Profile details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificateProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Profiles]
      summary: Update profile
      operationId: updateProfile
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CertificateProfile"
      responses:
        "200":
          description: Profile updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificateProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Profiles]
      summary: Delete profile
      operationId: deleteProfile
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Profile deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Teams ───────────────────────────────────────────────────────────
  /api/v1/teams:
    get:
      tags: [Teams]
      summary: List teams
      operationId: listTeams
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of teams
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Team"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Teams]
      summary: Create team
      operationId: createTeam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Team"
      responses:
        "201":
          description: Team created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Team"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/teams/{id}:
    get:
      tags: [Teams]
      summary: Get team
      operationId: getTeam
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Team details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Team"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Teams]
      summary: Update team
      operationId: updateTeam
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Team"
      responses:
        "200":
          description: Team updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Team"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Teams]
      summary: Delete team
      operationId: deleteTeam
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Team deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Owners ──────────────────────────────────────────────────────────
  /api/v1/owners:
    get:
      tags: [Owners]
      summary: List owners
      operationId: listOwners
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of owners
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Owner"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Owners]
      summary: Create owner
      operationId: createOwner
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Owner"
      responses:
        "201":
          description: Owner created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Owner"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/owners/{id}:
    get:
      tags: [Owners]
      summary: Get owner
      operationId: getOwner
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Owner details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Owner"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Owners]
      summary: Update owner
      operationId: updateOwner
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Owner"
      responses:
        "200":
          description: Owner updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Owner"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Owners]
      summary: Delete owner
      operationId: deleteOwner
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Owner deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Agent Groups ───────────────────────────────────────────────────
  /api/v1/agent-groups:
    get:
      tags: [Agent Groups]
      summary: List agent groups
      operationId: listAgentGroups
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of agent groups
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/AgentGroup"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Agent Groups]
      summary: Create agent group
      operationId: createAgentGroup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentGroup"
      responses:
        "201":
          description: Agent group created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agent-groups/{id}:
    get:
      tags: [Agent Groups]
      summary: Get agent group
      operationId: getAgentGroup
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Agent group details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Agent Groups]
      summary: Update agent group
      operationId: updateAgentGroup
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentGroup"
      responses:
        "200":
          description: Agent group updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Agent Groups]
      summary: Delete agent group
      operationId: deleteAgentGroup
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Agent group deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agent-groups/{id}/members:
    get:
      tags: [Agent Groups]
      summary: List agent group members
      description: Returns agents matching the group's dynamic criteria plus manually included members.
      operationId: listAgentGroupMembers
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: List of member agents
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Agent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Audit ───────────────────────────────────────────────────────────
  /api/v1/audit:
    get:
      tags: [Audit]
      summary: List audit events
      operationId: listAuditEvents
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of audit events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/AuditEvent"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/audit/{id}:
    get:
      tags: [Audit]
      summary: Get audit event
      operationId: getAuditEvent
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Audit event details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Notifications ──────────────────────────────────────────────────
  /api/v1/notifications:
    get:
      tags: [Notifications]
      summary: List notifications
      operationId: listNotifications
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of notifications
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/NotificationEvent"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/notifications/{id}:
    get:
      tags: [Notifications]
      summary: Get notification
      operationId: getNotification
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Notification details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/notifications/{id}/read:
    post:
      tags: [Notifications]
      summary: Mark notification as read
      operationId: markNotificationAsRead
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Marked as read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Stats ───────────────────────────────────────────────────────────
  /api/v1/stats/summary:
    get:
      tags: [Stats]
      summary: Dashboard summary
      operationId: getDashboardSummary
      responses:
        "200":
          description: High-level system metrics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DashboardSummary"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/stats/certificates-by-status:
    get:
      tags: [Stats]
      summary: Certificate status breakdown
      operationId: getCertificatesByStatus
      responses:
        "200":
          description: Certificate counts by status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status_counts:
                    type: array
                    items:
                      type: object
                      properties:
                        status:
                          type: string
                        count:
                          type: integer
                          format: int64
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/stats/expiration-timeline:
    get:
      tags: [Stats]
      summary: Expiration timeline
      operationId: getExpirationTimeline
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 365
      responses:
        "200":
          description: Certificates expiring per day
          content:
            application/json:
              schema:
                type: object
                properties:
                  buckets:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        count:
                          type: integer
                          format: int64
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/stats/job-trends:
    get:
      tags: [Stats]
      summary: Job success/failure trends
      operationId: getJobTrends
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 365
      responses:
        "200":
          description: Job trends per day
          content:
            application/json:
              schema:
                type: object
                properties:
                  trends:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        completed:
                          type: integer
                          format: int64
                        failed:
                          type: integer
                          format: int64
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/stats/issuance-rate:
    get:
      tags: [Stats]
      summary: Certificate issuance rate
      operationId: getIssuanceRate
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 365
      responses:
        "200":
          description: Issuance count per day
          content:
            application/json:
              schema:
                type: object
                properties:
                  rate:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        count:
                          type: integer
                          format: int64
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Metrics ─────────────────────────────────────────────────────────
  /api/v1/metrics:
    get:
      tags: [Metrics]
      summary: System metrics
      description: JSON metrics snapshot with gauges, counters, and uptime. See also /api/v1/metrics/prometheus for Prometheus exposition format.
      operationId: getMetrics
      responses:
        "200":
          description: Metrics snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetricsResponse"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Prometheus Metrics (M22) ──────────────────────────────────────
  /api/v1/metrics/prometheus:
    get:
      tags: [Metrics]
      summary: Prometheus metrics
      description: |
        Prometheus exposition format metrics. Compatible with Prometheus, Grafana Agent,
        Datadog Agent, Victoria Metrics, and any OpenMetrics scraper.
        Returns 11 metrics with certctl_ prefix (8 gauges, 2 counters, 1 info).
      operationId: getPrometheusMetrics
      responses:
        "200":
          description: Prometheus text format
          content:
            text/plain:
              schema:
                type: string
                description: "Prometheus exposition format (text/plain; version=0.0.4)"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Certificate Deployments (M20) ─────────────────────────────────
  /api/v1/certificates/{id}/deployments:
    get:
      tags: [Certificates]
      summary: List certificate deployments
      description: Returns deployment targets associated with this certificate.
      operationId: getCertificateDeployments
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Deployment targets for this certificate
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/DeploymentTarget"
                  total:
                    type: integer
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Discovery (M18b) ─────────────────────────────────────────────
  /api/v1/agents/{id}/discoveries:
    post:
      tags: [Discovery]
      summary: Submit discovery report
      description: |
        Agent submits a batch of discovered certificates from filesystem scanning.
        Server deduplicates by (fingerprint, agent_id, source_path) and records scan metadata.
      operationId: submitDiscoveryReport
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiscoveryReport"
      responses:
        "202":
          description: Report accepted and processed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveryScan"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovered-certificates:
    get:
      tags: [Discovery]
      summary: List discovered certificates
      description: Returns discovered certificates with optional filters by agent and triage status.
      operationId: listDiscoveredCertificates
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: agent_id
          in: query
          schema:
            type: string
          description: Filter by discovering agent
        - name: status
          in: query
          schema:
            type: string
            enum: [Unmanaged, Managed, Dismissed]
          description: Filter by triage status
      responses:
        "200":
          description: Paginated list of discovered certificates
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/DiscoveredCertificate"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovered-certificates/{id}:
    get:
      tags: [Discovery]
      summary: Get discovered certificate
      description: Returns a single discovered certificate by ID.
      operationId: getDiscoveredCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Discovered certificate details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveredCertificate"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovered-certificates/{id}/claim:
    post:
      tags: [Discovery]
      summary: Claim discovered certificate
      description: Links a discovered certificate to an existing managed certificate. Changes status to Managed.
      operationId: claimDiscoveredCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [managed_certificate_id]
              properties:
                managed_certificate_id:
                  type: string
                  description: ID of the managed certificate to link to
      responses:
        "200":
          description: Certificate claimed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovered-certificates/{id}/dismiss:
    post:
      tags: [Discovery]
      summary: Dismiss discovered certificate
      description: Marks a discovered certificate as dismissed (excluded from triage queue).
      operationId: dismissDiscoveredCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Certificate dismissed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovery-scans:
    get:
      tags: [Discovery]
      summary: List discovery scans
      description: Returns history of discovery scan executions with optional agent filter.
      operationId: listDiscoveryScans
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: agent_id
          in: query
          schema:
            type: string
          description: Filter by agent ID
      responses:
        "200":
          description: Paginated list of discovery scans
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/DiscoveryScan"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/discovery-summary:
    get:
      tags: [Discovery]
      summary: Discovery status summary
      description: Returns aggregate counts of discovered certificates by triage status.
      operationId: getDiscoverySummary
      responses:
        "200":
          description: Status counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  Unmanaged:
                    type: integer
                  Managed:
                    type: integer
                  Dismissed:
                    type: integer
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Network Scan Targets (M21) ───────────────────────────────────
  /api/v1/network-scan-targets:
    get:
      tags: [Network Scan]
      summary: List network scan targets
      description: Returns all configured network scan targets with CIDR ranges and ports.
      operationId: listNetworkScanTargets
      responses:
        "200":
          description: List of network scan targets
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/NetworkScanTarget"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Network Scan]
      summary: Create network scan target
      description: |
        Creates a new network scan target. CIDR ranges are validated and capped at /20
        (4096 IPs max per CIDR) to prevent accidental huge scans.
      operationId: createNetworkScanTarget
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NetworkScanTargetCreate"
      responses:
        "201":
          description: Target created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkScanTarget"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/network-scan-targets/{id}:
    get:
      tags: [Network Scan]
      summary: Get network scan target
      description: Returns a single network scan target by ID.
      operationId: getNetworkScanTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Network scan target details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkScanTarget"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [Network Scan]
      summary: Update network scan target
      description: Updates an existing network scan target.
      operationId: updateNetworkScanTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NetworkScanTargetCreate"
      responses:
        "200":
          description: Target updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkScanTarget"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Network Scan]
      summary: Delete network scan target
      description: Deletes a network scan target.
      operationId: deleteNetworkScanTarget
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Target deleted
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/network-scan-targets/{id}/scan:
    post:
      tags: [Network Scan]
      summary: Trigger network scan
      description: |
        Triggers an immediate scan of the specified target. Scans all configured CIDRs and ports
        concurrently (50 goroutines). Results feed into the discovery pipeline for deduplication.
      operationId: triggerNetworkScan
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "202":
          description: Scan completed with certificates found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveryScan"
        "200":
          description: Scan completed, no certificates found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

# ═══════════════════════════════════════════════════════════════════════
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key passed as Bearer token. Configure via CERTCTL_AUTH_SECRET.

  parameters:
    resourceId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Human-readable resource ID (e.g., mc-api-prod, t-platform)
    page:
      name: page
      in: query
      schema:
        type: integer
        default: 1
        minimum: 1
    per_page:
      name: per_page
      in: query
      schema:
        type: integer
        default: 50
        minimum: 1
        maximum: 500

  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    # ─── Common ──────────────────────────────────────────────────────
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
        request_id:
          type: string

    StatusResponse:
      type: object
      properties:
        status:
          type: string

    PaginationEnvelope:
      type: object
      properties:
        total:
          type: integer
          format: int64
        page:
          type: integer
        per_page:
          type: integer

    # ─── Certificates ────────────────────────────────────────────────
    CertificateStatus:
      type: string
      enum:
        - Pending
        - Active
        - Expiring
        - Expired
        - RenewalInProgress
        - Failed
        - Revoked
        - Archived

    ManagedCertificate:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        common_name:
          type: string
        sans:
          type: array
          items:
            type: string
        environment:
          type: string
        owner_id:
          type: string
        team_id:
          type: string
        issuer_id:
          type: string
        target_ids:
          type: array
          items:
            type: string
        renewal_policy_id:
          type: string
        certificate_profile_id:
          type: string
        status:
          $ref: "#/components/schemas/CertificateStatus"
        expires_at:
          type: string
          format: date-time
        tags:
          type: object
          additionalProperties:
            type: string
        last_renewal_at:
          type: string
          format: date-time
        last_deployment_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
        revocation_reason:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CertificateVersion:
      type: object
      properties:
        id:
          type: string
        certificate_id:
          type: string
        serial_number:
          type: string
        not_before:
          type: string
          format: date-time
        not_after:
          type: string
          format: date-time
        fingerprint_sha256:
          type: string
        pem_chain:
          type: string
        csr_pem:
          type: string
        key_algorithm:
          type: string
        key_size:
          type: integer
        created_at:
          type: string
          format: date-time

    RevocationReason:
      type: string
      enum:
        - unspecified
        - keyCompromise
        - caCompromise
        - affiliationChanged
        - superseded
        - cessationOfOperation
        - certificateHold
        - privilegeWithdrawn

    # ─── Issuers ─────────────────────────────────────────────────────
    IssuerType:
      type: string
      enum: [ACME, GenericCA, StepCA]

    Issuer:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        type:
          $ref: "#/components/schemas/IssuerType"
        config:
          type: object
          description: Issuer-specific configuration (varies by type)
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Targets ─────────────────────────────────────────────────────
    TargetType:
      type: string
      enum: [NGINX, Apache, HAProxy, F5, IIS]

    DeploymentTarget:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        type:
          $ref: "#/components/schemas/TargetType"
        agent_id:
          type: string
        config:
          type: object
          description: Target-specific configuration (varies by type)
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Agents ──────────────────────────────────────────────────────
    AgentStatus:
      type: string
      enum: [Online, Offline, Degraded]

    Agent:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        hostname:
          type: string
        status:
          $ref: "#/components/schemas/AgentStatus"
        last_heartbeat_at:
          type: string
          format: date-time
        registered_at:
          type: string
          format: date-time
        api_key_hash:
          type: string
        os:
          type: string
        architecture:
          type: string
        ip_address:
          type: string
        version:
          type: string

    WorkItem:
      type: object
      properties:
        id:
          type: string
        type:
          $ref: "#/components/schemas/JobType"
        certificate_id:
          type: string
        common_name:
          type: string
        sans:
          type: array
          items:
            type: string
        target_id:
          type: string
        target_type:
          type: string
        target_config:
          type: object
        status:
          $ref: "#/components/schemas/JobStatus"

    # ─── Jobs ────────────────────────────────────────────────────────
    JobType:
      type: string
      enum: [Issuance, Renewal, Deployment, Validation]

    JobStatus:
      type: string
      enum:
        - Pending
        - AwaitingCSR
        - AwaitingApproval
        - Running
        - Completed
        - Failed
        - Cancelled

    Job:
      type: object
      properties:
        id:
          type: string
        type:
          $ref: "#/components/schemas/JobType"
        certificate_id:
          type: string
        target_id:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        attempts:
          type: integer
        max_attempts:
          type: integer
        last_error:
          type: string
        scheduled_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time

    # ─── Policies ────────────────────────────────────────────────────
    PolicyType:
      type: string
      enum:
        - AllowedIssuers
        - AllowedDomains
        - RequiredMetadata
        - AllowedEnvironments
        - RenewalLeadTime

    PolicySeverity:
      type: string
      enum: [Warning, Error, Critical]

    PolicyRule:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        type:
          $ref: "#/components/schemas/PolicyType"
        config:
          type: object
          description: Policy-specific configuration (varies by type)
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PolicyViolation:
      type: object
      properties:
        id:
          type: string
        certificate_id:
          type: string
        rule_id:
          type: string
        message:
          type: string
        severity:
          $ref: "#/components/schemas/PolicySeverity"
        created_at:
          type: string
          format: date-time

    # ─── Profiles ────────────────────────────────────────────────────
    CertificateProfile:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        allowed_key_algorithms:
          type: array
          items:
            $ref: "#/components/schemas/KeyAlgorithmRule"
        max_ttl_seconds:
          type: integer
        allowed_ekus:
          type: array
          items:
            type: string
        required_san_patterns:
          type: array
          items:
            type: string
        spiffe_uri_pattern:
          type: string
        allow_short_lived:
          type: boolean
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    KeyAlgorithmRule:
      type: object
      properties:
        algorithm:
          type: string
          enum: [RSA, ECDSA, Ed25519]
        min_size:
          type: integer

    # ─── Teams ───────────────────────────────────────────────────────
    Team:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Owners ──────────────────────────────────────────────────────
    Owner:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
        team_id:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Agent Groups ────────────────────────────────────────────────
    AgentGroup:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        match_os:
          type: string
        match_architecture:
          type: string
        match_ip_cidr:
          type: string
        match_version:
          type: string
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Audit ───────────────────────────────────────────────────────
    ActorType:
      type: string
      enum: [User, System, Agent]

    AuditEvent:
      type: object
      properties:
        id:
          type: string
        actor:
          type: string
        actor_type:
          $ref: "#/components/schemas/ActorType"
        action:
          type: string
        resource_type:
          type: string
        resource_id:
          type: string
        details:
          type: object
        timestamp:
          type: string
          format: date-time

    # ─── Notifications ───────────────────────────────────────────────
    NotificationType:
      type: string
      enum:
        - ExpirationWarning
        - RenewalSuccess
        - RenewalFailure
        - DeploymentSuccess
        - DeploymentFailure
        - PolicyViolation
        - Revocation

    NotificationChannel:
      type: string
      enum: [Email, Webhook, Slack]

    NotificationEvent:
      type: object
      properties:
        id:
          type: string
        type:
          $ref: "#/components/schemas/NotificationType"
        certificate_id:
          type: string
        channel:
          $ref: "#/components/schemas/NotificationChannel"
        recipient:
          type: string
        message:
          type: string
        sent_at:
          type: string
          format: date-time
        status:
          type: string
        error:
          type: string
        created_at:
          type: string
          format: date-time

    # ─── Stats & Metrics ─────────────────────────────────────────────
    DashboardSummary:
      type: object
      properties:
        total_certificates:
          type: integer
          format: int64
        expiring_certificates:
          type: integer
          format: int64
        expired_certificates:
          type: integer
          format: int64
        revoked_certificates:
          type: integer
          format: int64
        active_agents:
          type: integer
          format: int64
        offline_agents:
          type: integer
          format: int64
        total_agents:
          type: integer
          format: int64
        pending_jobs:
          type: integer
          format: int64
        failed_jobs:
          type: integer
          format: int64
        complete_jobs:
          type: integer
          format: int64
        completed_at:
          type: string
          format: date-time

    MetricsResponse:
      type: object
      properties:
        gauge:
          type: object
          properties:
            certificate_total:
              type: integer
              format: int64
            certificate_active:
              type: integer
              format: int64
            certificate_expiring_soon:
              type: integer
              format: int64
            certificate_expired:
              type: integer
              format: int64
            certificate_revoked:
              type: integer
              format: int64
            agent_total:
              type: integer
              format: int64
            agent_online:
              type: integer
              format: int64
            job_pending:
              type: integer
              format: int64
        counter:
          type: object
          properties:
            job_completed_total:
              type: integer
              format: int64
            job_failed_total:
              type: integer
              format: int64
        uptime:
          type: object
          properties:
            uptime_seconds:
              type: integer
              format: int64
            server_started:
              type: string
              format: date-time
            measured_at:
              type: string
              format: date-time

    # ─── Discovery (M18b) ────────────────────────────────────────────
    DiscoveredCertificate:
      type: object
      properties:
        id:
          type: string
        fingerprint_sha256:
          type: string
        common_name:
          type: string
        sans:
          type: array
          items:
            type: string
        serial_number:
          type: string
        issuer_dn:
          type: string
        subject_dn:
          type: string
        not_before:
          type: string
          format: date-time
          nullable: true
        not_after:
          type: string
          format: date-time
          nullable: true
        key_algorithm:
          type: string
        key_size:
          type: integer
        is_ca:
          type: boolean
        source_path:
          type: string
        source_format:
          type: string
        agent_id:
          type: string
        discovery_scan_id:
          type: string
          nullable: true
        managed_certificate_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [Unmanaged, Managed, Dismissed]
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DiscoveryScan:
      type: object
      properties:
        id:
          type: string
        agent_id:
          type: string
        directories:
          type: array
          items:
            type: string
        certificates_found:
          type: integer
        certificates_new:
          type: integer
        errors_count:
          type: integer
        scan_duration_ms:
          type: integer
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true

    DiscoveryReport:
      type: object
      required: [agent_id, directories, certificates]
      properties:
        agent_id:
          type: string
        directories:
          type: array
          items:
            type: string
        certificates:
          type: array
          items:
            type: object
            properties:
              fingerprint_sha256:
                type: string
              common_name:
                type: string
              sans:
                type: array
                items:
                  type: string
              serial_number:
                type: string
              issuer_dn:
                type: string
              subject_dn:
                type: string
              not_before:
                type: string
              not_after:
                type: string
              key_algorithm:
                type: string
              key_size:
                type: integer
              is_ca:
                type: boolean
              pem_data:
                type: string
              source_path:
                type: string
              source_format:
                type: string
        errors:
          type: array
          items:
            type: string
        scan_duration_ms:
          type: integer

    StatusMessageResponse:
      type: object
      properties:
        status:
          type: string
        message:
          type: string

    # ─── Network Scan (M21) ──────────────────────────────────────────
    NetworkScanTarget:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        cidrs:
          type: array
          items:
            type: string
          description: CIDR ranges to scan (max /20 per CIDR)
        ports:
          type: array
          items:
            type: integer
          description: TCP ports to probe for TLS
        enabled:
          type: boolean
        scan_interval_hours:
          type: integer
          description: Hours between scheduled scans
        timeout_ms:
          type: integer
          description: Per-connection timeout in milliseconds
        last_scan_at:
          type: string
          format: date-time
          nullable: true
        last_scan_duration_ms:
          type: integer
          nullable: true
        last_scan_certs_found:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    NetworkScanTargetCreate:
      type: object
      required: [name, cidrs]
      properties:
        name:
          type: string
        cidrs:
          type: array
          items:
            type: string
          description: CIDR ranges (max /20 per CIDR, max 4096 IPs)
        ports:
          type: array
          items:
            type: integer
          description: TCP ports to probe (default [443])
        enabled:
          type: boolean
          default: true
        scan_interval_hours:
          type: integer
          default: 6
        timeout_ms:
          type: integer
          default: 5000
