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: https://localhost:8443
    description: Docker Compose demo (self-signed cert; pin with ./deploy/test/certs/ca.crt)

security:
  - bearerAuth: []

tags:
  - name: Certificates
    description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation
  - name: CRL & OCSP
    description: |
      Certificate revocation list (RFC 5280) and OCSP responder (RFC 6960).
      Served unauthenticated under `/.well-known/pki/*` (RFC 8615) so
      relying parties can retrieve revocation status without a certctl
      API key.
  - 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: RenewalPolicies
    description: Lifecycle renewal policies (distinct from compliance policy rules above)
  - 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
  - name: Health Monitoring
    description: Continuous TLS endpoint health checks with status tracking and probe history
  - name: Digest
    description: Scheduled certificate digest email notifications
  - name: Verification
    description: Post-deployment TLS endpoint fingerprint verification
  - name: EST
    description: Enrollment over Secure Transport (RFC 7030)
  - name: SCEP
    description: Simple Certificate Enrollment Protocol (RFC 8894)

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
                    # G-1 (P1): "jwt" removed from this enum after the silent
                    # auth downgrade was identified — no JWT middleware ships
                    # with certctl. Operators who need JWT/OIDC front certctl
                    # with an authenticating gateway (oauth2-proxy / Envoy /
                    # Traefik / Pomerium) and set CERTCTL_AUTH_TYPE=none
                    # upstream. See docs/architecture.md "Authenticating-
                    # gateway pattern".
                    enum: [api-key, 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

  /api/v1/version:
    get:
      tags: [Health]
      summary: Build identity (version, commit, Go runtime)
      description: |
        Returns the running server's build identity. Served without
        auth so rollout systems and blackbox probes can read it without
        Bearer credentials. U-3 ride-along (cat-u-no_version_endpoint).
        Excluded from audit logging because rollout polling would
        otherwise dominate the audit trail.

        The Version field follows a fallback ladder: ldflags-supplied
        value > VCS commit SHA > "dev". Commit / Modified / BuildTime
        come from runtime/debug.BuildInfo (Go 1.18+ stamps these on
        every module-tracked build). GoVersion is runtime.Version().
      security: []
      operationId: getVersion
      responses:
        "200":
          description: Build identity
          content:
            application/json:
              schema:
                type: object
                required: [version, commit, modified, build_time, go_version]
                properties:
                  version:
                    type: string
                    description: Release tag (ldflags-supplied) or VCS SHA fallback or "dev"
                    example: v2.0.51
                  commit:
                    type: string
                    description: Git SHA from runtime/debug.BuildInfo (vcs.revision); empty when not VCS-tracked
                  modified:
                    type: boolean
                    description: True when build had uncommitted changes (vcs.modified)
                  build_time:
                    type: string
                    description: RFC 3339 build timestamp (vcs.time); empty when not VCS-tracked
                  go_version:
                    type: string
                    description: Go toolchain version that compiled the binary (runtime.Version())
                    example: go1.25.9

  # ─── 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"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Certificates]
      summary: Archive certificate
      operationId: archiveCertificate
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Certificate archived
        "404":
          $ref: "#/components/responses/NotFound"
        "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"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "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"

  # ─── Bulk Revocation ─────────────────────────────────────────────────
  /api/v1/certificates/bulk-revoke:
    post:
      tags: [Certificates]
      summary: Bulk revoke certificates
      description: |
        Revokes all certificates matching the given filter criteria. At least one criterion
        is required (safety guard against accidental mass revocation). Reuses the single-cert
        revocation flow per certificate with partial-failure tolerance.
      operationId: bulkRevokeCertificates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkRevokeRequest"
      responses:
        "200":
          description: Bulk revocation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkRevokeResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/bulk-renew:
    post:
      tags: [Certificates]
      summary: Bulk renew certificates by criteria or explicit IDs
      description: |
        Enqueues a renewal job for every matching managed certificate. Mirrors POST
        /api/v1/certificates/bulk-revoke shape exactly so operators who already know
        that contract have zero new surface to learn. L-1 closure
        (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped per-cert HTTP calls;
        post-L-1 it's a single POST. Status filter: certs in
        Archived/Revoked/Expired/RenewalInProgress are silent-skipped (TotalSkipped++)
        rather than returned as errors. Asynchronous: the action ENQUEUES jobs the
        scheduler picks up; per-cert {certificate_id, job_id} pairs are returned in
        enqueued_jobs. NOT admin-gated — bulk renewal is non-destructive.
      operationId: bulkRenewCertificates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkRenewRequest"
      responses:
        "200":
          description: Bulk renewal result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkRenewResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/bulk-reassign:
    post:
      tags: [Certificates]
      summary: Bulk reassign owner (and optionally team) for a set of certificates
      description: |
        Updates owner_id (required) and team_id (optional) on every certificate in
        certificate_ids. Skips certs already owned by the target (silent no-op,
        TotalSkipped++). L-2 closure (cat-l-8a1fb258a38a). Narrower than bulk-renew:
        explicit IDs only, no criteria-mode. The OwnerID is validated upfront — a
        non-existent owner returns 400 before any cert is touched. Verb chosen as
        POST (not PATCH) for codebase consistency with bulk-revoke and bulk-renew.
      operationId: bulkReassignCertificates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkReassignRequest"
      responses:
        "200":
          description: Bulk reassignment result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkReassignResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Certificate Export ──────────────────────────────────────────────
  /api/v1/certificates/{id}/export/pem:
    get:
      tags: [Certificates]
      summary: Export certificate as PEM
      description: |
        Returns the certificate and its chain in PEM format. By default returns JSON
        with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the
        full PEM chain as a file download with Content-Disposition headers.
      operationId: exportCertificatePEM
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: download
          in: query
          schema:
            type: string
            enum: ["true"]
          description: Set to "true" to get a file download instead of JSON.
      responses:
        "200":
          description: PEM export
          content:
            application/json:
              schema:
                type: object
                properties:
                  cert_pem:
                    type: string
                    description: Leaf certificate PEM
                  chain_pem:
                    type: string
                    description: Intermediate/root chain PEM
                  full_pem:
                    type: string
                    description: Full PEM chain (cert + intermediates)
            application/x-pem-file:
              schema:
                type: string
                format: binary
                description: Full PEM file (when download=true)
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/certificates/{id}/export/pkcs12:
    post:
      tags: [Certificates]
      summary: Export certificate as PKCS#12
      description: |
        Returns a PKCS#12 (.p12) bundle containing the certificate and chain.
        Private keys are NOT included — they live on agents and never touch the control plane.
        The bundle is encrypted with the provided password (or empty password if omitted).
      operationId: exportCertificatePKCS12
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  description: Password to encrypt the PKCS#12 bundle (can be empty)
      responses:
        "200":
          description: PKCS#12 binary
          content:
            application/x-pkcs12:
              schema:
                type: string
                format: binary
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── PKI (CRL & OCSP, RFC 5280 / 6960 / 8615) ──────────────────────
  #
  # Relying parties (browsers, OpenSSL clients, OCSP stapling sidecars,
  # mTLS clients) cannot present a certctl Bearer token, so these two
  # endpoints are unauthenticated and live under the RFC 8615
  # `.well-known` namespace. They were previously mounted at
  # /api/v1/crl/{issuer_id} and /api/v1/ocsp/{issuer_id}/{serial}; those
  # paths were removed in M-006.
  #
  # The non-standard JSON CRL endpoint (GET /api/v1/crl) was also
  # removed — RFC 5280 defines only the DER wire format.
  /.well-known/pki/crl/{issuer_id}:
    get:
      tags: [CRL & OCSP]
      summary: Get DER-encoded X.509 CRL (RFC 5280)
      description: |
        Returns a DER-encoded CRL signed by the issuing CA (RFC 5280 §5),
        served unauthenticated per RFC 8615 `.well-known` semantics so
        relying parties can retrieve it without a certctl API key.
        Validity is 24 hours.
      operationId: getDERCRL
      security: []
      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

  /.well-known/pki/ocsp/{issuer_id}/{serial}:
    get:
      tags: [CRL & OCSP]
      summary: OCSP responder (RFC 6960)
      description: |
        Returns a signed OCSP response (good/revoked/unknown) for the
        given serial number per RFC 6960 §2.1, served unauthenticated
        per RFC 8615 so relying parties and OCSP stapling sidecars can
        query revocation status without a certctl API key.
      operationId: handleOCSP
      security: []
      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"

  /api/v1/targets/{id}/test:
    post:
      tags: [Targets]
      summary: Test target connection
      description: |
        Checks target connectivity by verifying the assigned agent's heartbeat status
        (agent reported within the last 5 minutes). Always returns HTTP 200 — the
        connectivity result is reflected in the response body's `status` field
        (`success` when the agent is reachable, `failed` otherwise).
      operationId: testTargetConnection
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Connection test result (success or failed in body)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "400":
          $ref: "#/components/responses/BadRequest"

  # ─── 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"
        "409":
          $ref: "#/components/responses/Conflict"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/agents/retired:
    get:
      tags: [Agents]
      summary: List retired agents
      description: |
        I-004: opt-in listing of soft-retired agents. The default
        `GET /api/v1/agents` endpoint filters retired rows out; this is the
        dedicated surface for reading them back (e.g., the operator UI's
        "Retired" tab, audit and forensics workflows). Pagination defaults
        match the default agent listing (page=1, per_page=50, max 500). Go
        1.22's enhanced ServeMux routes `/agents/retired` to this handler
        via the literal-beats-pattern-var precedence rule, so the sibling
        `/agents/{id}` route does not shadow it.
      operationId: listRetiredAgents
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of retired 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"

  /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"
    delete:
      tags: [Agents]
      summary: Soft-retire agent
      description: |
        I-004: soft-retirement. The agent row is preserved (so its audit
        trail and historical job links remain intact) and `retired_at` is
        stamped. A retired agent receives `410 Gone` on subsequent
        heartbeats so it can shut down cleanly.

        Behavior matrix:

        | Scenario | Query | Status | Body |
        | --- | --- | --- | --- |
        | Clean retire (no active dependencies) | none | `200` | `RetireAgentResponse` with `cascade=false`, zero counts |
        | Blocked by active targets/certs/jobs | none | `409` | `BlockedByDependenciesResponse` with per-bucket counts |
        | Force-cascade retire | `force=true&reason=...` | `200` | `RetireAgentResponse` with `cascade=true`, pre-cascade counts |
        | Idempotent re-retire | either | `204` | (empty — downstream consumers break on stray bodies) |
        | `force=true` without reason | `force=true` | `400` | ErrorResponse (ErrForceReasonRequired) |
        | Reserved sentinel agent | any | `403` | ErrorResponse (ErrAgentIsSentinel) |
        | Unknown agent id | any | `404` | ErrorResponse |

        Sentinel agents are the four reserved identities backing non-agent
        discovery subsystems (`server-scanner`, `cloud-aws-sm`,
        `cloud-azure-kv`, `cloud-gcp-sm`). Retiring them would orphan the
        scanner or a cloud secret-manager source, so the handler refuses
        unconditionally — even with `force=true`.
      operationId: retireAgent
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: force
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: |
            Cascade-retire active downstream targets, certificates, and
            jobs. When `true`, a non-empty `reason` is required. A
            malformed value (anything strconv.ParseBool rejects) is
            silently treated as `false` so a typoed query can never
            accidentally enable the cascade.
        - name: reason
          in: query
          required: false
          schema:
            type: string
          description: |
            Human-readable reason recorded on the retired row and in the
            immutable audit trail. Required (non-empty after trimming)
            when `force=true`.
      responses:
        "200":
          description: |
            Agent retired (clean retire or successful force-cascade). Body
            is `RetireAgentResponse`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetireAgentResponse"
        "204":
          description: |
            Idempotent retire — the agent was already retired. Response
            body is empty (the 200-path shape does not apply, and
            downstream clients that tee responses into dashboards would
            break on spurious bodies).
        "400":
          description: |
            `force=true` was sent without a non-empty `reason`
            (ErrForceReasonRequired).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            Agent is a reserved sentinel and cannot be retired even with
            `?force=true` (ErrAgentIsSentinel).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Blocked by active downstream dependencies. Body carries
            per-bucket counts so the operator UI can show the user which
            dependency is holding up the retire. Re-run with
            `?force=true&reason=...` to cascade.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BlockedByDependenciesResponse"
        "405":
          description: Method not allowed (only DELETE, GET are routed to this path)
        "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).

        I-004: a retired agent still polling the heartbeat endpoint receives
        `410 Gone` so `cmd/agent` detects the terminal signal and shuts down
        cleanly instead of looping forever against a decommissioned identity.
        The retired-agent check runs before any "not found" string match so
        it can never be masked by a sibling error branch.
      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"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          description: |
            I-004: the agent has been soft-retired. The agent process should
            treat this as a terminal signal and shut down cleanly.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "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"

  /api/v1/jobs/{id}/verify:
    post:
      tags: [Verification]
      summary: Record post-deployment verification result
      description: |
        Agents submit the result of probing a deployed certificate's live TLS endpoint.
        Compares the served certificate's SHA-256 fingerprint against the expected
        fingerprint. Best-effort: failures are recorded on the job but do not roll
        back the deployment.
      operationId: verifyDeployment
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyDeploymentRequest"
      responses:
        "200":
          description: Verification result recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id:
                    type: string
                  verified:
                    type: boolean
                  verified_at:
                    type: string
                    format: date-time
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{id}/verification:
    get:
      tags: [Verification]
      summary: Get post-deployment verification status
      description: |
        Returns the stored verification result for a deployment job — expected
        and observed SHA-256 fingerprints, verified flag, and timestamp.
      operationId: getJobVerification
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Verification result for the job
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerificationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "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"

  # ─── Renewal Policies ────────────────────────────────────────────────
  # G-1: lifecycle policies (rp-* ids, table renewal_policies). DISTINCT from
  # /api/v1/policies above, which returns compliance rules (pol-* ids, table
  # policy_rules). `managed_certificates.renewal_policy_id` FK points at
  # renewal_policies(id) — populating that dropdown from /api/v1/policies
  # caused 23503 FK violations; hence this endpoint.
  /api/v1/renewal-policies:
    get:
      tags: [RenewalPolicies]
      summary: List renewal policies
      operationId: listRenewalPolicies
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of renewal policies
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/RenewalPolicy"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [RenewalPolicies]
      summary: Create renewal policy
      operationId: createRenewalPolicy
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RenewalPolicyCreateRequest"
      responses:
        "201":
          description: Renewal policy created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenewalPolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: Duplicate policy name
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/renewal-policies/{id}:
    get:
      tags: [RenewalPolicies]
      summary: Get renewal policy
      operationId: getRenewalPolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Renewal policy details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenewalPolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      tags: [RenewalPolicies]
      summary: Update renewal policy
      operationId: updateRenewalPolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RenewalPolicyUpdateRequest"
      responses:
        "200":
          description: Renewal policy updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenewalPolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Duplicate policy name
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [RenewalPolicies]
      summary: Delete renewal policy
      operationId: deleteRenewalPolicy
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Renewal policy deleted
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Policy in use by one or more certificates (FK restrict)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "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"
        - name: status
          in: query
          required: false
          description: |
            Filter by lifecycle status. I-005: `dead` powers the Dead letter
            tab on the GUI; empty/omitted returns the default all-statuses
            listing to preserve pre-I-005 behavior.
          schema:
            type: string
            enum: [pending, sent, failed, dead, read]
      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"

  /api/v1/notifications/{id}/requeue:
    post:
      tags: [Notifications]
      summary: Requeue a dead notification
      description: |
        I-005: flip a notification from the `dead` dead-letter queue back to
        `pending` so the retry sweep (default 2 minutes) picks it up on its
        next tick. Used by operators after fixing the underlying delivery
        failure (SMTP config, webhook endpoint, etc.). Clears `next_retry_at`
        and resets the `retry_count` budget; `last_error` is preserved for
        audit continuity.
      operationId: requeueNotification
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Requeued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "405":
          description: Method not allowed (POST only)
        "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"

  # ─── Health Monitoring ─────────────────────────────────────────────
  /api/v1/health-checks:
    get:
      tags: [Health Monitoring]
      summary: List endpoint health checks
      description: |
        Lists all TLS endpoint health checks with optional filtering by status, certificate, or network scan target.
        Includes current status, last probe results, and probe history summary.
      operationId: listHealthChecks
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [Healthy, Degraded, Down, CertMismatch]
          description: Filter by health status
        - name: certificate_id
          in: query
          schema:
            type: string
          description: Filter by certificate ID
        - name: network_scan_target_id
          in: query
          schema:
            type: string
          description: Filter by network scan target ID
        - name: enabled
          in: query
          schema:
            type: boolean
          description: Filter by enabled/disabled state
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: List of health checks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/EndpointHealthCheck"
                  total:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      tags: [Health Monitoring]
      summary: Create health check
      description: Creates a new manual health check for an endpoint.
      operationId: createHealthCheck
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint, check_interval_seconds]
              properties:
                endpoint:
                  type: string
                  description: "host:port to monitor"
                  example: "api.example.com:443"
                expected_fingerprint:
                  type: string
                  description: Expected certificate SHA-256 fingerprint (optional)
                check_interval_seconds:
                  type: integer
                  minimum: 30
                  description: Probe frequency in seconds (default 300)
                timeout_ms:
                  type: integer
                  description: TLS connection timeout in milliseconds
      responses:
        "201":
          description: Health check created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndpointHealthCheck"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/health-checks/summary:
    get:
      tags: [Health Monitoring]
      summary: Health check summary
      description: Returns aggregate status counts for all health checks.
      operationId: getHealthCheckSummary
      responses:
        "200":
          description: Health check summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  healthy:
                    type: integer
                  degraded:
                    type: integer
                  down:
                    type: integer
                  cert_mismatch:
                    type: integer
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/health-checks/{id}:
    get:
      tags: [Health Monitoring]
      summary: Get health check
      operationId: getHealthCheck
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Health check detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndpointHealthCheck"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    put:
      tags: [Health Monitoring]
      summary: Update health check
      description: Update thresholds, interval, or expected fingerprint.
      operationId: updateHealthCheck
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                expected_fingerprint:
                  type: string
                check_interval_seconds:
                  type: integer
                timeout_ms:
                  type: integer
                enabled:
                  type: boolean
      responses:
        "200":
          description: Health check updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndpointHealthCheck"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      tags: [Health Monitoring]
      summary: Delete health check
      operationId: deleteHealthCheck
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "204":
          description: Health check deleted
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/health-checks/{id}/history:
    get:
      tags: [Health Monitoring]
      summary: Get probe history
      description: Returns historical probe records with status, response times, and errors.
      operationId: getHealthCheckHistory
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 1000
          description: Max number of records to return
      responses:
        "200":
          description: Probe history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/HealthHistoryEntry"
                  total:
                    type: integer
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/health-checks/{id}/acknowledge:
    post:
      tags: [Health Monitoring]
      summary: Acknowledge incident
      description: Mark a health check incident as acknowledged by the operator.
      operationId: acknowledgeHealthCheckIncident
      parameters:
        - $ref: "#/components/parameters/resourceId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                acknowledged_by:
                  type: string
                  description: Operator name or ID
      responses:
        "200":
          description: Incident acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndpointHealthCheck"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── Digest ────────────────────────────────────────────────────────
  /api/v1/digest/preview:
    get:
      tags: [Digest]
      summary: Preview digest email
      description: |
        Returns an HTML preview of the scheduled certificate digest email.
        This includes a summary of certificate status, pending jobs, and expiring certificates.
      operationId: previewDigest
      responses:
        "200":
          description: HTML digest email preview
          content:
            text/html:
              schema:
                type: string
                example: "<html>...</html>"
        "503":
          description: Digest service not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/digest/send:
    post:
      tags: [Digest]
      summary: Send digest email
      description: |
        Triggers immediate sending of the certificate digest email to configured recipients.
        If no explicit recipients are configured, sends to certificate owners.
      operationId: sendDigest
      responses:
        "200":
          description: Digest sent successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "503":
          description: Digest service not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusMessageResponse"
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── EST (RFC 7030) ────────────────────────────────────────────────
  /.well-known/est/cacerts:
    get:
      tags: [EST]
      summary: EST CA certificates distribution
      description: |
        Returns the CA certificate chain used to verify certctl-issued certificates.
        Response is a base64-encoded degenerate PKCS#7 SignedData (certs-only) per
        RFC 7030 §4.1.3.
      operationId: estCACerts
      security: []
      responses:
        "200":
          description: Base64-encoded PKCS#7 certs-only structure
          headers:
            Content-Transfer-Encoding:
              schema:
                type: string
                example: base64
          content:
            application/pkcs7-mime:
              schema:
                type: string
                format: byte
                description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
        "500":
          $ref: "#/components/responses/InternalError"

  /.well-known/est/simpleenroll:
    post:
      tags: [EST]
      summary: EST simple enrollment
      description: |
        Enrolls a new certificate from a PKCS#10 CSR per RFC 7030 §4.2.1.
        The CSR MAY be supplied as base64-encoded DER (EST standard wire format)
        or as PEM for convenience. Returns a base64-encoded PKCS#7 certs-only
        structure containing the issued certificate.
      operationId: estSimpleEnroll
      security: []
      requestBody:
        required: true
        description: "Base64-encoded DER PKCS#10 CSR, or PEM-encoded CSR"
        content:
          application/pkcs10:
            schema:
              type: string
              format: byte
      responses:
        "200":
          description: Base64-encoded PKCS#7 cert-only response with issued certificate
          headers:
            Content-Transfer-Encoding:
              schema:
                type: string
                example: base64
          content:
            application/pkcs7-mime:
              schema:
                type: string
                format: byte
                description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
        "400":
          $ref: "#/components/responses/BadRequest"
        "405":
          description: Method not allowed (only POST accepted)
        "500":
          $ref: "#/components/responses/InternalError"

  /.well-known/est/simplereenroll:
    post:
      tags: [EST]
      summary: EST simple re-enrollment
      description: |
        Re-enrolls an existing certificate (same as simpleenroll in certctl's
        implementation — re-enrollment is treated as a fresh issuance) per
        RFC 7030 §4.2.2.
      operationId: estSimpleReEnroll
      security: []
      requestBody:
        required: true
        description: "Base64-encoded DER PKCS#10 CSR, or PEM-encoded CSR"
        content:
          application/pkcs10:
            schema:
              type: string
              format: byte
      responses:
        "200":
          description: Base64-encoded PKCS#7 cert-only response with re-issued certificate
          headers:
            Content-Transfer-Encoding:
              schema:
                type: string
                example: base64
          content:
            application/pkcs7-mime:
              schema:
                type: string
                format: byte
                description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
        "400":
          $ref: "#/components/responses/BadRequest"
        "405":
          description: Method not allowed (only POST accepted)
        "500":
          $ref: "#/components/responses/InternalError"

  /.well-known/est/csrattrs:
    get:
      tags: [EST]
      summary: EST CSR attributes
      description: |
        Returns attributes the EST client should include in its CSR per
        RFC 7030 §4.5. certctl currently returns an empty attribute set
        (HTTP 204) — profile-based constraints are enforced server-side
        during enrollment rather than advertised here.
      operationId: estCSRAttrs
      security: []
      responses:
        "200":
          description: Base64-encoded CsrAttrs (when non-empty)
          headers:
            Content-Transfer-Encoding:
              schema:
                type: string
                example: base64
          content:
            application/csrattrs:
              schema:
                type: string
                format: byte
        "204":
          description: No CSR attributes defined (empty response)
        "500":
          $ref: "#/components/responses/InternalError"

  # ─── SCEP (RFC 8894) ──────────────────────────────────────────────
  /scep:
    get:
      tags: [SCEP]
      summary: SCEP operation dispatch (GET)
      description: |
        Single SCEP entry point dispatched by the `operation` query parameter
        per RFC 8894. GET is used for capability discovery (`GetCACaps`) and
        CA certificate retrieval (`GetCACert`).
      operationId: scepGet
      security: []
      parameters:
        - name: operation
          in: query
          required: true
          schema:
            type: string
            enum: [GetCACaps, GetCACert, PKIOperation]
          description: SCEP operation selector
        - name: message
          in: query
          required: false
          schema:
            type: string
          description: Optional SCEP message parameter (base64-encoded for GET PKIOperation)
      responses:
        "200":
          description: |
            Success. Content-Type varies by operation:
            - `GetCACaps` → `text/plain` capability list
            - `GetCACert` (single cert) → `application/x-x509-ca-cert` (raw DER)
            - `GetCACert` (chain) → `application/x-x509-ca-ra-cert` (PKCS#7)
            - `PKIOperation` → `application/x-pki-message` (PKCS#7 SignedData)
          content:
            text/plain:
              schema:
                type: string
                description: "SCEP capabilities (GetCACaps only)"
            application/x-x509-ca-cert:
              schema:
                type: string
                format: binary
                description: "CA certificate DER (GetCACert single)"
            application/x-x509-ca-ra-cert:
              schema:
                type: string
                format: binary
                description: "CA chain PKCS#7 (GetCACert chain)"
            application/x-pki-message:
              schema:
                type: string
                format: binary
                description: "PKCS#7 SignedData response (PKIOperation)"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [SCEP]
      summary: SCEP PKIOperation (POST)
      description: |
        SCEP enrollment / renewal / revocation request per RFC 8894.
        Request body is a PKCS#7 SignedData envelope wrapping the PKCS#10 CSR
        or a degenerate raw CSR (fallback). The challenge password in the CSR
        attributes is validated against `CERTCTL_SCEP_CHALLENGE_PASSWORD` when
        configured.
      operationId: scepPost
      security: []
      parameters:
        - name: operation
          in: query
          required: true
          schema:
            type: string
            enum: [PKIOperation]
      requestBody:
        required: true
        description: PKCS#7 SignedData envelope wrapping a PKCS#10 CSR (or raw CSR as fallback)
        content:
          application/x-pki-message:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: PKCS#7 SignedData PKIMessage response
          content:
            application/x-pki-message:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "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"
    Conflict:
      description: Resource conflict
      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:
      # D-5 (cat-f-ae0d06b6588f, master): per-issuance fields
      # (serial_number, fingerprint_sha256, key_algorithm, key_size,
      # issued_at) are intentionally NOT declared here. They live on
      # CertificateVersion (per-issuance evidence) and are fetched via
      # /api/v1/certificates/{id}/versions. ManagedCertificate is the
      # management envelope; CertificateVersion is the issuance record.
      # Pre-D-5 the TS Certificate interface had them as optional and
      # the dashboard's Key Algorithm / Key Size rows always rendered
      # '—' as a result. The TS trim restores parity with this schema.
      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
      required:
        - name
        - common_name
        - renewal_policy_id
        - issuer_id
        - owner_id
        - team_id

    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

    BulkRevokeRequest:
      type: object
      required: [reason]
      properties:
        reason:
          $ref: "#/components/schemas/RevocationReason"
        profile_id:
          type: string
          description: Revoke all certificates matching this profile
        owner_id:
          type: string
          description: Revoke all certificates owned by this owner
        agent_id:
          type: string
          description: Revoke all certificates deployed via this agent
        issuer_id:
          type: string
          description: Revoke all certificates issued by this issuer
        team_id:
          type: string
          description: Revoke all certificates owned by members of this team
        certificate_ids:
          type: array
          items:
            type: string
          description: Explicit list of certificate IDs to revoke

    BulkRevokeResult:
      type: object
      properties:
        total_matched:
          type: integer
          description: Number of certificates matching the criteria
        total_revoked:
          type: integer
          description: Number of certificates successfully revoked
        total_skipped:
          type: integer
          description: Number of certificates skipped (already revoked or archived)
        total_failed:
          type: integer
          description: Number of certificates that failed to revoke
        errors:
          type: array
          items:
            type: object
            properties:
              certificate_id:
                type: string
              error:
                type: string
          description: Per-certificate error details for failed revocations

    # L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
    # bulk-renew + bulk-reassign request/result schemas. Mirror
    # BulkRevokeRequest/Result envelope shape so frontend bulk-result
    # rendering is one helper. See internal/domain/bulk_renewal.go +
    # internal/domain/bulk_reassignment.go for the Go-side source of
    # truth.
    BulkRenewRequest:
      type: object
      description: Criteria for bulk renewal. At least one selector required.
      properties:
        profile_id:
          type: string
          description: Renew all certificates matching this profile
        owner_id:
          type: string
          description: Renew all certificates owned by this owner
        agent_id:
          type: string
          description: Renew all certificates deployed via this agent
        issuer_id:
          type: string
          description: Renew all certificates issued by this issuer
        team_id:
          type: string
          description: Renew all certificates owned by members of this team
        certificate_ids:
          type: array
          items:
            type: string
          description: Explicit list of certificate IDs to renew

    BulkEnqueuedJob:
      type: object
      properties:
        certificate_id:
          type: string
        job_id:
          type: string
          description: ID of the renewal job created for this certificate

    BulkRenewResult:
      type: object
      properties:
        total_matched:
          type: integer
          description: Number of certificates matching the criteria
        total_enqueued:
          type: integer
          description: Number of renewal jobs successfully created
        total_skipped:
          type: integer
          description: Certs already RenewalInProgress / Revoked / Archived / Expired (silent no-op)
        total_failed:
          type: integer
          description: Number of certificates whose enqueue path returned an error
        enqueued_jobs:
          type: array
          items:
            $ref: "#/components/schemas/BulkEnqueuedJob"
          description: Per-certificate {certificate_id, job_id} pairs for the successful enqueue path
        errors:
          type: array
          items:
            type: object
            properties:
              certificate_id:
                type: string
              error:
                type: string
          description: Per-certificate error details for the failure path

    BulkReassignRequest:
      type: object
      required: [certificate_ids, owner_id]
      properties:
        certificate_ids:
          type: array
          items:
            type: string
          description: Explicit list of certificate IDs to reassign
        owner_id:
          type: string
          description: Required. New owner_id for every cert in certificate_ids.
        team_id:
          type: string
          description: Optional. When non-empty, also updates team_id on every cert.

    BulkReassignResult:
      type: object
      properties:
        total_matched:
          type: integer
        total_reassigned:
          type: integer
          description: Number of certs whose owner_id (and optionally team_id) was actually mutated
        total_skipped:
          type: integer
          description: Certs already owned by the target (silent no-op)
        total_failed:
          type: integer
        errors:
          type: array
          items:
            type: object
            properties:
              certificate_id:
                type: string
              error:
                type: string

    # ─── Issuers ─────────────────────────────────────────────────────
    IssuerType:
      type: string
      enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS, AWSACMPCA, Entrust, GlobalSign, EJBCA]

    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, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH, WinCertStore, JavaKeystore, KubernetesSecrets]

    DeploymentTarget:
      type: object
      required: [name, type, agent_id]
      properties:
        id:
          type: string
        name:
          type: string
        type:
          $ref: "#/components/schemas/TargetType"
        agent_id:
          type: string
          description: |
            ID of the agent that manages this target. Required because
            deployment_targets.agent_id is a NOT NULL foreign key to agents(id)
            (migration 000001). Empty or nonexistent agent IDs are rejected
            with HTTP 400 by the service layer (see C-002 in the coverage-gap
            audit).
        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
        # G-2 (P1): the `api_key_hash` field was REMOVED from this
        # schema after cat-s5-apikey_leak audit closure. The DB column
        # still exists (migrations/000001_initial_schema.up.sql) and
        # the server still populates the in-memory struct for the
        # auth-lookup path (repository.AgentRepository::GetByAPIKey),
        # but the JSON wire shape no longer carries it — see
        # internal/domain/connector.go::Agent::APIKeyHash + MarshalJSON
        # for the redaction enforcement and docs/architecture.md ER
        # diagram for the database-vs-API distinction. Do NOT re-add
        # the field here without first removing the JSON-shape redaction
        # in the domain package; the CI guardrail at
        # .github/workflows/ci.yml will block re-introduction either way.
        os:
          type: string
        architecture:
          type: string
        ip_address:
          type: string
        version:
          type: string
        retired_at:
          type: string
          format: date-time
          nullable: true
          description: |
            I-004: soft-retirement timestamp. `null` (or field absent) means the
            agent is active. A non-null value is the canonical "retired" state —
            the operational `status` column is preserved at retirement time as
            the last-seen value, but `retired_at` is the source of truth for
            filtering agents out of active listings.
        retired_reason:
          type: string
          nullable: true
          description: |
            I-004: human-readable reason captured at retirement time. Only set
            when the agent was retired via `?force=true&reason=...` cascade; a
            default soft-retire leaves this field null.

    AgentDependencyCounts:
      type: object
      description: |
        I-004: preflight counts of active downstream rows that would be
        orphaned by retiring an agent. Returned in the 409
        `blocked_by_dependencies` body so the operator UI can tell the user
        which bucket is blocking the retire, and also in the 200 response
        body on a successful `?force=true` cascade as a snapshot of what
        was cascaded.
      properties:
        active_targets:
          type: integer
          description: Deployment targets with this agent assigned and retired_at IS NULL
        active_certificates:
          type: integer
          description: Certificates currently deployed via one of this agent's active targets
        pending_jobs:
          type: integer
          description: Jobs with agent_id=this in status Pending, AwaitingCSR, AwaitingApproval, or Running

    RetireAgentResponse:
      type: object
      description: |
        I-004: response body for a successful retire on DELETE /api/v1/agents/{id}.
        Returned on both clean retires (cascade=false, zero counts) and
        force-cascade retires (cascade=true, counts snapshot of the
        pre-cascade dependency state). The 204 idempotent-retire path does
        NOT emit this body — re-retiring an already-retired agent returns
        an empty response.
      properties:
        retired_at:
          type: string
          format: date-time
        already_retired:
          type: boolean
          description: |
            Always false on the 200 response — the already-retired path
            returns 204 No Content with no body. Surfaced in the schema
            only so downstream consumers have a complete field map.
        cascade:
          type: boolean
          description: True when the retire was invoked with ?force=true
        counts:
          $ref: "#/components/schemas/AgentDependencyCounts"

    BlockedByDependenciesResponse:
      type: object
      description: |
        I-004: 409 response body for a retire request blocked by active
        downstream dependencies. Returned when `force=true` is not set and
        any of the three counts is non-zero. The operator UI renders these
        counts so the human can retire or reassign the blocking rows
        before re-running the retire, or tick the force checkbox to cascade.
      properties:
        error:
          type: string
          example: blocked_by_dependencies
        message:
          type: string
        counts:
          $ref: "#/components/schemas/AgentDependencyCounts"

    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
        - CertificateLifetime

    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
        severity:
          $ref: "#/components/schemas/PolicySeverity"
          description: Severity level applied to violations of this rule. Defaults to Warning on create when omitted.
        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

    # ─── Renewal Policies ─────────────────────────────────────────────
    # G-1: renewal_policies table — lifecycle policies, referenced by
    # managed_certificates.renewal_policy_id ON DELETE RESTRICT. Distinct
    # from PolicyRule above (compliance rules, table policy_rules).
    RenewalPolicy:
      type: object
      required:
        - id
        - name
        - renewal_window_days
        - auto_renew
        - max_retries
        - retry_interval_seconds
        - alert_thresholds_days
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: Human-readable ID, prefixed `rp-` (e.g., `rp-default`).
        name:
          type: string
          description: Unique display name (UNIQUE in DB).
        renewal_window_days:
          type: integer
          minimum: 1
          maximum: 365
          description: Days before expiry to trigger renewal.
        auto_renew:
          type: boolean
          description: Whether renewal is triggered automatically by the scheduler.
        max_retries:
          type: integer
          minimum: 0
          maximum: 10
          description: Maximum renewal retry attempts on failure.
        retry_interval_seconds:
          type: integer
          minimum: 60
          maximum: 86400
          description: Seconds to wait between retry attempts.
        alert_thresholds_days:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 365
          description: Days-before-expiry thresholds at which to emit alerts.
        certificate_profile_id:
          type: string
          nullable: true
          description: Optional certificate profile binding. Read-only at this endpoint; UI does not currently edit this field.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RenewalPolicyCreateRequest:
      type: object
      required:
        - name
      properties:
        id:
          type: string
          description: Optional human-readable ID. Auto-generated from name when omitted.
        name:
          type: string
          minLength: 1
          maxLength: 255
        renewal_window_days:
          type: integer
          minimum: 1
          maximum: 365
          default: 30
        auto_renew:
          type: boolean
          default: true
        max_retries:
          type: integer
          minimum: 0
          maximum: 10
          description: Required. Not defaulted — 0 is a valid operator choice.
        retry_interval_seconds:
          type: integer
          minimum: 60
          maximum: 86400
          default: 3600
        alert_thresholds_days:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 365
          default: [30, 14, 7, 0]

    RenewalPolicyUpdateRequest:
      type: object
      description: Partial update. Omitted fields are left unchanged.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        renewal_window_days:
          type: integer
          minimum: 1
          maximum: 365
        auto_renew:
          type: boolean
        max_retries:
          type: integer
          minimum: 0
          maximum: 10
        retry_interval_seconds:
          type: integer
          minimum: 60
          maximum: 86400
        alert_thresholds_days:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 365

    # ─── 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
          description: Extended Key Usages to include in issued certificates
          items:
            type: string
            enum:
              - serverAuth
              - clientAuth
              - codeSigning
              - emailProtection
              - timeStamping
        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
          enum: [pending, sent, failed, dead, read]
          description: |
            Notification lifecycle status. I-005 adds `dead` for notifications
            that exhausted their 5-attempt retry budget and were moved to the
            dead-letter queue; operators triage these in the GUI's Dead letter
            tab and use POST /notifications/{id}/requeue to resurrect them.
        error:
          type: string
        retry_count:
          type: integer
          description: |
            Number of delivery attempts made. I-005 retry-sweep field; caps
            at max_attempts=5 before the notification transitions to `dead`.
        next_retry_at:
          type: string
          format: date-time
          description: |
            When the next retry attempt is scheduled. I-005 retry-sweep field;
            null for `sent`, `dead`, and `read` statuses. Backoff follows
            `min(2^retry_count * 1m, 1h)`.
        last_error:
          type: string
          description: |
            Most recent transient delivery error (SMTP failure, webhook 5xx,
            etc.). I-005 retry-sweep field; surfaced on the Dead letter tab
            so operators can triage without chasing server logs.
        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

    EndpointHealthCheck:
      type: object
      properties:
        id:
          type: string
          description: Health check ID
        endpoint:
          type: string
          description: "Target endpoint (host:port)"
          example: "api.example.com:443"
        certificate_id:
          type: string
          nullable: true
          description: Associated managed certificate ID (if from deployment)
        network_scan_target_id:
          type: string
          nullable: true
          description: Associated network scan target ID (if auto-created)
        expected_fingerprint:
          type: string
          nullable: true
          description: Expected certificate SHA-256 fingerprint
        status:
          type: string
          enum: [Healthy, Degraded, Down, CertMismatch]
          description: Current health status
        enabled:
          type: boolean
        check_interval_seconds:
          type: integer
          description: Frequency of TLS probes (seconds)
        timeout_ms:
          type: integer
          description: TLS connection timeout (milliseconds)
        consecutive_failures:
          type: integer
          description: Number of consecutive probe failures
        last_checked_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of last probe
        last_success_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of last successful probe
        last_failure_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of last failed probe
        last_transition_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of last status transition
        failure_reason:
          type: string
          nullable: true
          description: Reason for last failure
        acknowledged:
          type: boolean
          description: Whether the current status has been acknowledged
        acknowledged_by:
          type: string
          nullable: true
          description: Operator name who acknowledged (if applicable)
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    HealthHistoryEntry:
      type: object
      properties:
        id:
          type: string
        health_check_id:
          type: string
        status:
          type: string
          enum: [Healthy, Degraded, Down, CertMismatch]
        response_time_ms:
          type: integer
          nullable: true
          description: Time to connect and complete TLS handshake (milliseconds)
        observed_fingerprint:
          type: string
          nullable: true
          description: SHA-256 fingerprint of certificate observed on endpoint
        tls_version:
          type: string
          nullable: true
          description: TLS version (e.g., TLSv1.3)
        cipher_suite:
          type: string
          nullable: true
          description: Cipher suite used in TLS handshake
        cert_subject:
          type: string
          nullable: true
          description: Subject DN of observed certificate
        cert_issuer:
          type: string
          nullable: true
          description: Issuer DN of observed certificate
        cert_not_before:
          type: string
          format: date-time
          nullable: true
        cert_not_after:
          type: string
          format: date-time
          nullable: true
        failure_reason:
          type: string
          nullable: true
          description: Error message if probe failed
        checked_at:
          type: string
          format: date-time
          description: Timestamp of this probe

    # ─── Verification (M25) ──────────────────────────────────────────
    VerifyDeploymentRequest:
      type: object
      required: [target_id, expected_fingerprint, actual_fingerprint, verified]
      properties:
        target_id:
          type: string
          description: Deployment target the agent probed
        expected_fingerprint:
          type: string
          description: SHA-256 fingerprint of the certificate that should be served (hex, lowercase)
        actual_fingerprint:
          type: string
          description: SHA-256 fingerprint observed on the live TLS endpoint (hex, lowercase)
        verified:
          type: boolean
          description: True when expected and actual fingerprints match
        error:
          type: string
          nullable: true
          description: Error message when probe failed or fingerprints differ

    VerificationResult:
      type: object
      properties:
        job_id:
          type: string
        target_id:
          type: string
        expected_fingerprint:
          type: string
          description: SHA-256 fingerprint (hex) of the certificate deployed by this job
        actual_fingerprint:
          type: string
          description: SHA-256 fingerprint (hex) observed on the live TLS endpoint
        verified:
          type: boolean
        verified_at:
          type: string
          format: date-time
        error:
          type: string
          description: Error message when verification failed
