openapi: "3.1.0"
info:
  title: Cetacean API
  version: "1.0"
  description: |
    Observability and management API for Docker Swarm Mode clusters.
    For a detailed walkthrough of conventions, query parameters, SSE streaming, and write operations, see the [API guide](/api).

    Uses content negotiation — same URLs serve JSON, HTML (SPA), or SSE based on
    the Accept header. Append `.json` or `.html` to any path to force a content type.

    The API is versioned by media type, not by path: `application/vnd.cetacean.v1+json`
    pins version 1 and is answered as `application/json`, as is `application/ld+json`.
    Neither is repeated under each operation. Endpoints that list an Atom feed also
    serve JSON Feed, as `application/feed+json` or with a `.feed` suffix.

    All collection responses include JSON-LD `@context` and `@type` fields.
    Detail responses include `@context`, `@id`, and `@type`.
    Error responses follow RFC 9457 (Problem Details).

    Every response (except `/-/` meta endpoints) includes:
    - `Link: </api>; rel="service-desc"; type="application/json"` — pointer to this spec
    - `Link: </api/asyncapi>; rel="service-desc"; type="application/vnd.aai.asyncapi+json;version=3.0.0"` —
      the AsyncAPI description of the SSE streams
    - `Link: </api/context.jsonld>; rel="describedby"` — JSON-LD context
    - `Vary: Accept` — content negotiation indicator

    JSON, Atom and graph responses over 1 KiB honour `Accept-Encoding: zstd`
    and `gzip`, answering with `Content-Encoding` and `Vary: Accept-Encoding`.
    A compressed representation carries its coding as an ETag suffix inside
    the quotes (`"abc123-zstd"`); the hash itself is always of the
    uncompressed body, so such a validator can be sent back as `If-Match`
    unchanged. `/search` is never compressed.

servers:
  - url: /

security:
  - cookieAuth: []
  - bearerAuth: []
  - {} # Allow unauthenticated (none mode)

tags:
  - name: Meta
    description: Health checks and operational endpoints (no content negotiation, no discovery links)
  - name: Authentication
    description: Authentication endpoints (OIDC login flow, identity)
    externalDocs:
      description: Authentication guide
      url: /authentication
  - name: Cluster
    description: Cluster-wide information, swarm config, disk usage, and plugins
  - name: Plugins
    description: Docker plugin management (install, enable, disable, upgrade, configure, remove)
  - name: Nodes
    description: Swarm nodes
    externalDocs:
      description: Dashboard guide
      url: /dashboard
  - name: Services
    description: Swarm services
    externalDocs:
      description: Dashboard guide
      url: /dashboard
  - name: Tasks
    description: Swarm tasks (enriched with ServiceName and NodeHostname)
  - name: Stacks
    description: Derived stacks (from com.docker.stack.namespace labels)
  - name: Configs
    description: Swarm configs
  - name: Secrets
    description: Swarm secrets (data always redacted)
  - name: Networks
    description: Docker networks
  - name: Volumes
    description: Docker volumes
  - name: Search
    description: Cross-resource global search
  - name: History
    description: Resource change history (ring buffer)
  - name: Topology
    description: Network and placement topology views
  - name: Monitoring
    description: Prometheus metrics proxy and monitoring status
    externalDocs:
      description: Monitoring setup guide
      url: /monitoring
  - name: Events
    description: Real-time SSE event stream
  - name: Recommendations
    description: Recommendation engine for sizing, config hygiene, operational health, and cluster topology
    externalDocs:
      description: Recommendations guide
      url: /recommendations
  - name: Documentation
    description: API spec and JSON-LD context
    externalDocs:
      description: API guide
      url: /api

x-tagGroups:
  - name: Meta & Docs
    tags: [Meta, Documentation, Monitoring]
  - name: Authentication
    tags: [Authentication]
  - name: Cluster
    tags: [Cluster, Nodes, Plugins]
  - name: Workloads
    tags: [Services, Tasks, Stacks]
  - name: Resources
    tags: [Configs, Secrets, Networks, Volumes]
  - name: Discovery & Insights
    tags: [Search, History, Topology, Events, Recommendations]

paths:
  # ── Meta ──────────────────────────────────────────────────────────────

  /-/health:
    get:
      operationId: getHealth
      tags: [Meta]
      summary: Health check
      description: |
        Always returns 200 with version info and, when a watcher is attached, whether the
        cache is still being kept current. No content negotiation.
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  version:
                    type: string
                  commit:
                    type: string
                  buildDate:
                    type: string
                  operationsLevel:
                    type: integer
                    description: Configured write operations level (0=read-only, 1=operational, 2=configuration, 3=impactful)
                    example: 1
                  watcher:
                    type: object
                    description: |
                      Whether the cache behind every other endpoint is still being kept current.
                      When the Docker connection fails, Cetacean keeps serving the state it last
                      read and readiness keeps passing — this is what says the data has stopped
                      moving. Absent when no watcher is attached.
                    properties:
                      connected:
                        type: boolean
                        description: Whether the Docker event stream is established.
                      lastSyncAt:
                        type: string
                        format: date-time
                        description: When a full sync last reached the engine. Absent before the first one succeeds.
                      lastSyncAgeSeconds:
                        type: number
                        description: Seconds since that sync. Compare against the five-minute re-sync interval.
              example:
                status: ok
                version: 0.11.0
                commit: 607a5c7
                buildDate: "2025-03-15T10:00:00Z"
                operationsLevel: 1
                watcher:
                  connected: true
                  lastSyncAt: "2025-03-15T10:00:00Z"
                  lastSyncAgeSeconds: 12.4

  /-/ready:
    get:
      operationId: getReady
      tags: [Meta]
      summary: Readiness check
      description: |
        Returns 200 after the first sync that reaches the swarm — startup,
        reconnect or periodic — 503 until then.
      security: []
      responses:
        "200":
          description: Ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ready
        "503":
          description: Not ready (no sync has reached the swarm yet)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: not_ready

  /-/resync:
    post:
      operationId: resyncCache
      tags: [Meta]
      summary: Re-read the whole cluster from Docker
      description: |
        Discards nothing, but re-fetches every resource from the Docker Engine
        and replaces the in-memory cache with the result. Useful when the cache
        has drifted — an event missed during a rapid stack deploy, say. A call
        made while a re-sync is already running waits for that one and shares
        its result.

        Not gated on the operations level: it re-reads the cluster and never
        changes it, so a read-only deployment can still use it. It does require
        authentication and a grant, unlike the other `/-/` endpoints, because
        each call sweeps the whole Docker API.
      responses:
        "200":
          description: The re-sync completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  durationMs:
                    type: integer
                    format: int64
                    example: 14
        "401":
          description: Not authenticated
        "403":
          $ref: "#/components/responses/Forbidden"
        "502":
          description: >
            The re-sync failed (`ENG006`); the cache is unchanged. The detail names what each
            list Docker refused answered.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "504":
          description: The re-sync ran out of time before Docker answered (`ENG005`); the cache is unchanged
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

  /-/metrics:
    get:
      operationId: getSelfMetrics
      tags: [Monitoring]
      summary: Cetacean's own Prometheus metrics
      description: >
        Serves Prometheus exposition format with Cetacean's operational metrics
        (HTTP requests, SSE connections, cache state, etc.).
        Disabled when CETACEAN_SELF_METRICS=false.
      security: []
      responses:
        "200":
          description: Prometheus metrics
          content:
            text/plain:
              schema:
                type: string
              example: |
                # HELP cetacean_http_requests_total Total HTTP requests handled
                # TYPE cetacean_http_requests_total counter
                cetacean_http_requests_total{method="GET",path="/services",status="200"} 1423
                cetacean_http_requests_total{method="GET",path="/nodes",status="200"} 891
                # HELP cetacean_sse_clients Current SSE client count
                # TYPE cetacean_sse_clients gauge
                cetacean_sse_clients 12
                # HELP cetacean_cache_resources Resources currently cached
                # TYPE cetacean_cache_resources gauge
                cetacean_cache_resources{type="service"} 47
                cetacean_cache_resources{type="task"} 142
                cetacean_cache_resources{type="node"} 5

  /metrics/status:
    get:
      operationId: getMonitoringStatus
      tags: [Monitoring]
      summary: Monitoring auto-detection status
      description: |
        Probes Prometheus for node-exporter and cAdvisor targets.
        Returns detection status for the guided setup UI.
      responses:
        "200":
          description: Monitoring status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitoringStatus"
              examples:
                healthy:
                  summary: Prometheus configured with full coverage
                  value:
                    prometheusConfigured: true
                    prometheusReachable: true
                    queryable: true
                    nodeExporter: { targets: 3, nodes: 3 }
                    cadvisor: { targets: 3, nodes: 3 }
                partial:
                  summary: Prometheus reachable but missing exporters on some nodes
                  value:
                    prometheusConfigured: true
                    prometheusReachable: true
                    queryable: true
                    nodeExporter: { targets: 2, nodes: 3 }
                    cadvisor: { targets: 3, nodes: 3 }
                unreachable:
                  summary: Prometheus configured but not reachable
                  value:
                    prometheusConfigured: true
                    prometheusReachable: false
                    queryable: true
                    error: prometheus unreachable
                    nodeExporter: { targets: 0, nodes: 3 }
                    cadvisor: { targets: 0, nodes: 3 }
                unconfigured:
                  summary: Prometheus not configured
                  value:
                    prometheusConfigured: false
                    prometheusReachable: false
                    queryable: true
                    nodeExporter: { targets: 0, nodes: 3 }
                    cadvisor: { targets: 0, nodes: 3 }
                notPermitted:
                  summary: Caller lacks read on "*", so the dashboard hides its charts
                  value:
                    prometheusConfigured: true
                    prometheusReachable: true
                    queryable: false
                    nodeExporter: { targets: 3, nodes: 3 }
                    cadvisor: { targets: 3, nodes: 3 }

  /metrics/labels:
    get:
      operationId: getMetricsLabels
      tags: [Monitoring]
      summary: Prometheus label names
      description: |
        Proxied Prometheus label names endpoint. Returns all known label names,
        optionally filtered by series selectors via `match[]`.

        Under an ACL policy, requires a `read` grant on the bare resource `*`.
      parameters:
        - name: match[]
          in: query
          description: Series selector(s) to filter labels by
          schema:
            type: string
          example: 'up{job="node-exporter"}'
        - name: start
          in: query
          description: Start of the time range to consider (RFC 3339 or Unix timestamp)
          schema:
            type: string
          example: "2026-04-13T00:00:00Z"
        - name: end
          in: query
          description: End of the time range to consider (RFC 3339 or Unix timestamp)
          schema:
            type: string
          example: "2026-04-13T01:00:00Z"
      responses:
        "200":
          description: Label names
          content:
            application/json:
              schema:
                type: object
                description: Prometheus labels response (proxied as-is)
        "403":
          $ref: "#/components/responses/Forbidden"
        "503":
          description: Prometheus not configured

  /metrics/labels/{name}:
    get:
      operationId: getMetricsLabelValues
      tags: [Monitoring]
      summary: Prometheus label values
      description: |
        Proxied Prometheus label values endpoint. Returns all known values
        for a given label name, optionally filtered by series selectors via `match[]`.

        Under an ACL policy, requires a `read` grant on the bare resource `*`.
      parameters:
        - name: name
          in: path
          required: true
          description: Label name
          schema:
            type: string
          example: instance
        - name: match[]
          in: query
          description: Series selector(s) to filter values by
          schema:
            type: string
          example: 'up{job="node-exporter"}'
      responses:
        "200":
          description: Label values
          content:
            application/json:
              schema:
                type: object
                description: Prometheus label values response (proxied as-is)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "503":
          description: Prometheus not configured

  /-/docker-latest-version:
    get:
      operationId: getDockerLatestVersion
      tags: [Meta]
      summary: Latest Docker Engine version
      description: >
        Returns the latest stable Docker Engine version from the GitHub releases API. Cached.
        Not served when `server.version_check` is off.
      responses:
        "200":
          description: Latest version info
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: string
                    example: "27.5.1"

  /-/licenses:
    get:
      summary: Open-source licenses (projected)
      description: Flattened list of bundled open-source components (Go modules and frontend npm packages) with license metadata. Public.
      responses:
        "200":
          description: Projected licenses document
          content:
            application/json:
              schema:
                type: object
                required: [components]
                properties:
                  generatedAt: { type: string }
                  components:
                    type: array
                    items:
                      type: object
                      required: [name, ecosystem, licenses]
                      properties:
                        name: { type: string }
                        version: { type: string }
                        description: { type: string }
                        ecosystem: { type: string, enum: [go, npm, other] }
                        homepage: { type: string, format: uri }
                        repository: { type: string, format: uri }
                        licenses:
                          type: array
                          items:
                            type: object
                            properties:
                              id: { type: string }
                              name: { type: string }
                              url: { type: string, format: uri }
  /-/licenses/texts/{id}:
    get:
      tags: [Meta]
      summary: Full text of one dependency's license or notice
      description: >-
        Returns the verbatim license or NOTICE text identified by `id`, which
        comes from a component's `textId` or `noticeId` on `/-/licenses`. Ids
        are content hashes, so a response is immutable and cacheable
        indefinitely. Public — no authentication required.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: A `textId` or `noticeId` from the licenses document.
      responses:
        "200":
          description: The license or notice text
          content:
            text/plain:
              schema: { type: string }
        "304": { description: Not modified }
        "404":
          description: No text with that identifier
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ProblemDetail" }
  /-/notices:
    get:
      tags: [Meta]
      summary: Full third-party attribution document
      description: >-
        Returns the complete THIRD_PARTY_LICENSES document: the curated
        preamble followed by every bundled dependency's license and any
        NOTICE it ships. Public — no authentication required.
      responses:
        "200":
          description: The third-party attribution document
          content:
            text/plain:
              schema: { type: string }
        "304": { description: Not modified }
  /-/sbom.cdx.json:
    get:
      summary: Software Bill of Materials (CycloneDX)
      description: >-
        Raw CycloneDX SBOM for Go and frontend npm dependencies. Public. The
        response is served with a `version=1.6` media-type parameter
        (`application/vnd.cyclonedx+json; version=1.6`).
      responses:
        "200":
          description: CycloneDX JSON document
          content:
            application/vnd.cyclonedx+json:
              schema:
                type: object

  /metrics:
    get:
      operationId: getMetrics
      tags: [Monitoring]
      summary: Prometheus query (content-negotiated)
      description: |
        Content-negotiated Prometheus query endpoint.
        - `application/json`: proxied query result (instant or range based on parameters)
        - `text/event-stream`: SSE stream of periodic instant query results
        - `text/html`: metrics console SPA

        Instant vs range is determined by presence of `start`+`end` parameters.
        Returns 503 if Prometheus is not configured. Under an ACL policy, the JSON
        and SSE forms require a `read` grant on the bare resource `*`.
      parameters:
        - name: query
          in: query
          required: true
          description: PromQL query expression
          schema:
            type: string
          example: 'sum(rate(container_cpu_usage_seconds_total[5m])) by (container_label_com_docker_swarm_service_name)'
        - name: time
          in: query
          description: Evaluation timestamp (instant queries)
          schema:
            type: string
          example: "2026-04-13T12:00:00Z"
        - name: start
          in: query
          description: Range start timestamp (triggers range query)
          schema:
            type: string
          example: "2026-04-13T11:00:00Z"
        - name: end
          in: query
          description: Range end timestamp (triggers range query)
          schema:
            type: string
          example: "2026-04-13T12:00:00Z"
        - name: step
          in: query
          description: Query resolution step width (range queries and SSE)
          schema:
            type: string
          example: 60s
        - name: range
          in: query
          description: Lookback duration in seconds (SSE initial range query)
          schema:
            type: string
          example: "3600"
      responses:
        "200":
          description: Prometheus query result or SSE stream
          content:
            application/json:
              schema:
                type: object
                description: Prometheus query result (proxied as-is)
            text/event-stream:
              schema:
                type: object
                description: |
                  SSE stream with `initial` (full range) and `point` (single value) events
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Prometheus not configured

  # ── Authentication ──────────────────────────────────────────────────

  /profile:
    get:
      operationId: getProfile
      tags: [Authentication]
      summary: Current user profile
      description: |
        Content-negotiated identity endpoint. Returns the authenticated user's
        identity as JSON (with ETag), or serves the SPA for browser requests.

        Unlike `/auth/whoami`, this endpoint is content-negotiated and includes
        `Cache-Control: no-store`.
      responses:
        "200":
          description: Authenticated user identity
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/Identity"
                  - type: object
                    properties:
                      permissions:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
                        description: Per-resource permission map (present when ACL is active)
              example:
                "@context": /api/context.jsonld
                "@id": /profile
                "@type": Profile
                subject: user@example.com
                displayName: Jane Doe
                email: user@example.com
                groups: [engineering, ops]
                provider: oidc
                permissions:
                  "service:webapp-*": [read, write]
                  "stack:myapp": [read, write]
                  "node:*": [read]
        "304":
          $ref: "#/components/responses/NotModified"
        "401":
          description: Not authenticated
        "403":
          $ref: "#/components/responses/Forbidden"

  /auth/whoami:
    get:
      summary: Get current identity
      operationId: whoami
      tags: [Authentication]
      responses:
        "200":
          description: Current user identity
          headers:
            Cache-Control:
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/Identity"
              example:
                "@context": /api/context.jsonld
                "@id": /auth/whoami
                "@type": Identity
                subject: user@example.com
                displayName: Jane Doe
                email: user@example.com
                groups: [engineering, ops]
                provider: oidc
        "401":
          description: Not authenticated
        "403":
          $ref: "#/components/responses/Forbidden"

  /auth/login:
    get:
      summary: Initiate OIDC login
      operationId: authLogin
      tags: [Authentication]
      description: Redirects to OIDC provider for authentication. Only available in OIDC mode.
      security: []
      parameters:
        - name: redirect
          in: query
          description: URL to redirect to after login
          schema:
            type: string
          example: /services
      responses:
        "302":
          description: Redirect to OIDC provider

  /auth/callback:
    get:
      summary: OIDC callback
      operationId: authCallback
      tags: [Authentication]
      description: Handles the OIDC authorization code callback. Only available in OIDC mode.
      security: []
      parameters:
        - name: code
          in: query
          required: true
          description: Authorization code returned by the OIDC provider
          schema:
            type: string
          example: 4/0AY0e-g7...
        - name: state
          in: query
          required: true
          description: Opaque state parameter that must match the value sent in the initial authorization request
          schema:
            type: string
          example: af0ifjsldkj
      responses:
        "302":
          description: Redirect to original URL after successful authentication
        "400":
          description: Invalid state parameter

  /auth/logout:
    post:
      summary: Logout
      operationId: authLogout
      tags: [Authentication]
      description: |
        Clears the session cookie and redirects to home. Only available in OIDC mode.
        Uses `http.NewCrossOriginProtection` to reject cross-site POSTs.
      responses:
        "302":
          description: Redirect to home

  # ── Documentation ────────────────────────────────────────────────────

  /:
    get:
      operationId: getEntrypoint
      tags: [Documentation]
      summary: Web API entry point
      description: |
        Content-negotiated. HTML requests get the dashboard; JSON requests get
        a JSON-LD document naming every top-level collection, so a client that
        knows only the origin can find the rest of the API from here.

        The RFC 9727 catalog at `/.well-known/api-catalog` is unauthenticated
        and points at this document, which is not: discovery is public, the
        API behind it is not.
      responses:
        "200":
          description: Entry point
          content:
            application/json:
              schema:
                type: object
              example:
                "@context": /api/context.jsonld
                "@id": /
                "@type": EntryPoint
                name: Cetacean
                resources:
                  nodes:
                    "@id": /nodes
                  services:
                    "@id": /services
                version: 0.11.0
            text/html:
              schema:
                type: string
                description: The dashboard
        "406":
          $ref: "#/components/responses/NotAcceptable"

  /index:
    get:
      operationId: getEntrypointAlias
      tags: [Documentation]
      summary: Web API entry point (alias)
      description: |
        The entry point under the name static hosting taught clients to expect.
        Content-negotiated exactly as `/` is, and `@id` still names `/`, so the
        two spellings share one identity. Served rather than redirected: the
        root has no spelling that carries a suffix, and the `/.html` a redirect
        would have to name is a path proxies are commonly hardened to refuse.
      responses:
        "200":
          description: Entry point
          content:
            application/json:
              schema:
                type: object

  /api:
    get:
      operationId: getApiDoc
      tags: [Documentation]
      summary: API documentation
      description: |
        Content-negotiated. HTML requests get the Scalar API playground, a
        request naming a YAML type gets this document as YAML, and all others
        get it as JSON.
      security: []
      responses:
        "200":
          description: API documentation
          content:
            application/json:
              schema:
                type: object
            text/html:
              schema:
                type: string
            application/vnd.oai.openapi:
              schema:
                type: string

  /api/openapi.yaml:
    get:
      operationId: getApiDocYaml
      tags: [Documentation]
      summary: OpenAPI description as YAML
      description: |
        This document as YAML, at the address the documentation site publishes
        it under. The source file is served verbatim, so its comments and key
        order are intact.
      security: []
      responses:
        "200":
          description: OpenAPI description
          content:
            application/vnd.oai.openapi:
              schema:
                type: string

  /api/asyncapi:
    get:
      operationId: getAsyncApiDoc
      tags: [Documentation]
      summary: AsyncAPI description of the event streams
      description: |
        Describes every Server-Sent Events stream this server serves — what
        each channel sends, the shape of its payloads, and which cursor dialect
        its `id:` belongs to. A request naming a YAML type gets YAML; everything
        else gets JSON.

        The document names this deployment's own origin in its `servers` block,
        so it is built per origin rather than served as a fixed file.
      security: []
      responses:
        "200":
          description: AsyncAPI description
          content:
            application/vnd.aai.asyncapi+json:
              schema:
                type: object
            application/vnd.aai.asyncapi+yaml:
              schema:
                type: string

  /api/asyncapi.yaml:
    get:
      operationId: getAsyncApiDocYaml
      tags: [Documentation]
      summary: AsyncAPI description as YAML
      description: |
        The same document as `/api/asyncapi`, always as YAML, keeping the
        source's comments and key order.
      security: []
      responses:
        "200":
          description: AsyncAPI description
          content:
            application/vnd.aai.asyncapi+yaml:
              schema:
                type: string

  /api/context.jsonld:
    get:
      operationId: getJsonLdContext
      tags: [Documentation]
      summary: JSON-LD context document
      description: Defines the vocabulary for JSON-LD terms used in responses.
      security: []
      responses:
        "200":
          description: JSON-LD context
          content:
            application/ld+json:
              schema:
                type: object
              example:
                "@context":
                  "@vocab": "urn:cetacean:"
                  schema: "https://schema.org/"
                  Collection: "urn:cetacean:Collection"
                  Service: "urn:cetacean:Service"
                  Node: "urn:cetacean:Node"
                  Task: "urn:cetacean:Task"
                  Stack: "urn:cetacean:Stack"
                  name: "urn:cetacean:name"
                  items: "urn:cetacean:items"
                  total: "urn:cetacean:total"
                  limit: "urn:cetacean:limit"
                  offset: "urn:cetacean:offset"

  /opensearch.xml:
    get:
      operationId: getOpenSearchDescription
      tags: [Documentation]
      summary: OpenSearch description document
      description: |
        Describes the cluster search so a browser can offer it from the address
        bar. Templates are absolute, built from `server.public_url` when set.
      responses:
        "200":
          description: OpenSearch description
          content:
            application/opensearchdescription+xml:
              schema:
                type: string
              example: |
                <?xml version="1.0" encoding="UTF-8"?>
                <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
                  <ShortName>Cetacean</ShortName>
                  <Description>Search services, stacks, nodes, tasks, configs, secrets, networks and volumes in the Swarm cluster</Description>
                  <InputEncoding>UTF-8</InputEncoding>
                  <Url type="text/html" template="https://cetacean.example.com/search?q={searchTerms}"/>
                </OpenSearchDescription>

  /.well-known/api-catalog:
    get:
      operationId: getAPICatalog
      tags: [Documentation]
      summary: API catalog
      description: |
        Lists the APIs this deployment publishes, as an RFC 9264 linkset at the
        RFC 9727 well-known URI. Each `item` names one API; the contexts beside
        it carry its description, documentation and status links. The MCP server
        appears only when enabled, and its metadata only when an authorization
        server is wired for it.
      security: []
      responses:
        "200":
          description: API catalog
          content:
            application/linkset+json:
              schema:
                type: object
                required: [linkset]
                properties:
                  linkset:
                    type: array
                    items:
                      type: object
                      required: [anchor]
                      properties:
                        anchor:
                          type: string
                          format: uri
                      additionalProperties:
                        type: array
                        items:
                          type: object
                          required: [href]
                          properties:
                            href:
                              type: string
                              format: uri
                            type:
                              type: string
                            title:
                              type: string
              example:
                linkset:
                  - anchor: "https://cetacean.example.com/.well-known/api-catalog"
                    item:
                      - href: "https://cetacean.example.com/"
                        title: "Cetacean web API"
                  - anchor: "https://cetacean.example.com/"
                    service-desc:
                      - href: "https://cetacean.example.com/api"
                        type: "application/json"
                        title: "OpenAPI description"
                      - href: "https://cetacean.example.com/api/asyncapi"
                        type: "application/vnd.aai.asyncapi+json"
                        title: "AsyncAPI description of the event streams"
                    describedby:
                      - href: "https://cetacean.example.com/api/context.jsonld"
                        type: "application/ld+json"
                        title: "JSON-LD context"
                    status:
                      - href: "https://cetacean.example.com/-/health"
                        type: "application/json"
                        title: "Health"

  /api/errors:
    get:
      operationId: listErrorCodes
      tags: [Documentation]
      summary: Error code reference
      description: |
        Lists all well-known error codes with their titles, HTTP status codes,
        descriptions, and suggestions. Browser requests (Accept: text/html) serve
        the SPA error reference page.
      security: []
      responses:
        "200":
          description: Collection of error definitions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/ErrorDef"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /api/errors/SVC001
                    "@type": ErrorDef
                    code: SVC001
                    title: Service Version Conflict
                    status: 409
                    description: The service was modified by another request since you last read it.
                    suggestion: Re-read the service and retry the operation.
                  - "@id": /api/errors/OPS001
                    "@type": ErrorDef
                    code: OPS001
                    title: Operations Level Too Low
                    status: 403
                    description: This write operation requires a higher operations level.
                    suggestion: Increase the operations level or contact an administrator.
                total: 2
                limit: 2
                offset: 0

  /api/errors/{code}:
    get:
      operationId: getErrorCode
      tags: [Documentation]
      summary: Error code detail
      description: |
        Returns documentation for a single error code including its title,
        HTTP status, description, and suggested resolution.
      security: []
      parameters:
        - name: code
          in: path
          required: true
          description: "Error code (e.g. SVC001, NOD003)"
          schema:
            type: string
            pattern: "^[A-Z]{3}\\d{3}$"
            example: SVC001
      responses:
        "200":
          description: Error definition
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ErrorDef"
              example:
                "@context": /api/context.jsonld
                "@id": /api/errors/SVC001
                "@type": ErrorDef
                code: SVC001
                title: Service Version Conflict
                status: 409
                description: The service was modified by another request since you last read it.
                suggestion: Re-read the service and retry the operation.
        "404":
          description: Unknown error code
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ProblemDetail" }

  # ── Events ───────────────────────────────────────────────────────────

  /events:
    get:
      operationId: getEvents
      tags: [Events]
      summary: Real-time SSE event stream
      description: |
        Content-negotiated event endpoint for resource changes.
        - `text/event-stream`: real-time SSE stream (batched within the configured interval, default 100 ms)
        - `application/atom+xml`: Atom feed of recent events
        - `application/feed+json`: JSON Feed of recent events
        - `text/html` (or no Accept): SPA

        Single SSE events use `event: <type>` (node, service, task, etc.).
        Batched events use `event: batch` with an array payload.

        Max 256 concurrent SSE clients. Returns 429 with Retry-After when exceeded.
      parameters:
        - name: types
          in: query
          description: Comma-separated event types to subscribe to (SSE only)
          schema:
            type: string
            example: node,service,task
      responses:
        "200":
          description: |
            SSE event stream. Each frame has the shape `event: <type>\ndata: <SSEEvent-JSON>\n\n`.
            The schema below describes the JSON payload of a single `data:` line.
          content:
            text/event-stream:
              schema:
                $ref: "#/components/schemas/SSEEvent"
              example:
                "@id": /services/r8m2x5nk3p7q
                "@type": Service
                type: service
                action: update
                id: r8m2x5nk3p7q
                resource:
                  ID: r8m2x5nk3p7q
                  Spec: { Name: webapp }
                  UpdateStatus: { State: updating }
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ── Cluster ──────────────────────────────────────────────────────────

  /cluster:
    get:
      operationId: getCluster
      tags: [Cluster]
      summary: Cluster overview
      description: Aggregated counts and status of all swarm resources.
      responses:
        "200":
          description: Cluster snapshot
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ClusterSnapshot"
              example:
                "@context": /api/context.jsonld
                "@id": /cluster
                "@type": Cluster
                nodeCount: 3
                serviceCount: 12
                taskCount: 42
                stackCount: 4
                tasksByState:
                  running: 38
                  complete: 3
                  failed: 1
                nodesReady: 3
                nodesDown: 0
                nodesDraining: 0
                totalCPU: 12
                totalMemory: 25769803776
                servicesConverged: 11
                servicesDegraded: 1
                reservedCPU: 8000000000
                reservedMemory: 17179869184
                lastSync: "2025-03-20T14:30:00Z"
                prometheusConfigured: true
        "304":
          $ref: "#/components/responses/NotModified"

  /cluster/metrics:
    get:
      operationId: getClusterMetrics
      tags: [Cluster]
      summary: Cluster resource utilization
      description: |
        CPU, memory, and disk utilization from Prometheus.
        Returns 404 if Prometheus is not configured.
      responses:
        "200":
          description: Cluster metrics
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ClusterMetrics"
              example:
                "@context": /api/context.jsonld
                "@id": /cluster/metrics
                "@type": ClusterMetrics
                cpu: { used: 4.22, total: 12, percent: 35.2 }
                memory: { used: 10737418240, total: 25769803776, percent: 41.7 }
                disk: { used: 53687091200, total: 214748364800, percent: 25.0 }
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"

  /cluster/capacity:
    get:
      operationId: getClusterCapacity
      tags: [Cluster]
      summary: Cluster resource capacity
      description: |
        Max single-node CPU and memory (for slider bounds) plus cluster-wide totals.
      responses:
        "200":
          description: Cluster capacity
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ClusterCapacity"
              example:
                "@context": /api/context.jsonld
                "@id": /cluster/capacity
                "@type": ClusterCapacity
                maxNodeCPU: 8
                maxNodeMemory: 17179869184
                totalCPU: 12
                totalMemory: 25769803776
                nodeCount: 3
        "304":
          $ref: "#/components/responses/NotModified"

  /swarm:
    get:
      operationId: getSwarm
      tags: [Cluster]
      summary: Swarm configuration
      description: >-
        Full swarm inspect result with raft config, CA config and the join tokens. The join
        tokens are empty unless the caller has write on `swarm:cluster` and operations level 3.
      responses:
        "200":
          description: Swarm info
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      swarm:
                        type: object
                        description: Docker swarm.Swarm object
                      managerAddr:
                        type: string
                        description: Address of the leader manager node
              example:
                "@context": /api/context.jsonld
                "@id": /swarm
                "@type": Swarm
                swarm:
                  ID: abc123def456ghi789jkl012mno345pq
                  Version: { Index: 412 }
                  CreatedAt: "2025-09-12T14:23:01.123456789Z"
                  UpdatedAt: "2026-04-13T10:15:22.987654321Z"
                  Spec:
                    Name: default
                    Orchestration:
                      TaskHistoryRetentionLimit: 5
                    Raft:
                      SnapshotInterval: 10000
                      KeepOldSnapshots: 0
                      LogEntriesForSlowFollowers: 500
                      ElectionTick: 10
                      HeartbeatTick: 1
                    Dispatcher:
                      HeartbeatPeriod: 5000000000
                    CAConfig:
                      NodeCertExpiry: 7776000000000000
                    EncryptionConfig:
                      AutoLockManagers: false
                  JoinTokens:
                    Worker: SWMTKN-1-abc...-def
                    Manager: SWMTKN-1-abc...-xyz
                managerAddr: 10.0.0.1:2377
        "304":
          $ref: "#/components/responses/NotModified"

  /swarm/orchestration:
    patch:
      operationId: patchSwarmOrchestration
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Cluster]
      summary: Patch swarm orchestration config
      description: Updates the swarm orchestration configuration (task history retention limit). Requires operations level 2.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              properties:
                TaskHistoryRetentionLimit:
                  type: integer
            example:
              TaskHistoryRetentionLimit: 10
      responses:
        "200":
          description: Updated orchestration config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      orchestration:
                        type: object
                        description: Docker swarm.OrchestrationConfig object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/raft:
    patch:
      operationId: patchSwarmRaft
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Cluster]
      summary: Patch swarm raft config
      description: Updates the swarm Raft configuration (snapshot interval, log entries, election/heartbeat ticks). Requires operations level 2.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              properties:
                SnapshotInterval:
                  type: integer
                  format: uint64
                KeepOldSnapshots:
                  type: integer
                  format: uint64
                LogEntriesForSlowFollowers:
                  type: integer
                  format: uint64
                ElectionTick:
                  type: integer
                HeartbeatTick:
                  type: integer
            example:
              SnapshotInterval: 10000
              ElectionTick: 10
              HeartbeatTick: 1
      responses:
        "200":
          description: Updated raft config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      raft:
                        type: object
                        description: Docker swarm.RaftConfig object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/dispatcher:
    patch:
      operationId: patchSwarmDispatcher
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Cluster]
      summary: Patch swarm dispatcher config
      description: Updates the swarm dispatcher configuration (heartbeat period). Requires operations level 2.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              properties:
                HeartbeatPeriod:
                  type: integer
                  format: int64
                  description: Heartbeat period in nanoseconds
            example:
              HeartbeatPeriod: 5000000000
      responses:
        "200":
          description: Updated dispatcher config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      dispatcher:
                        type: object
                        description: Docker swarm.DispatcherConfig object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/ca:
    patch:
      operationId: patchSwarmCAConfig
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Patch swarm CA config
      description: Updates the swarm CA configuration (node cert expiry, external CAs). Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              properties:
                NodeCertExpiry:
                  type: integer
                  format: int64
                  description: Node certificate expiry in nanoseconds
            example:
              NodeCertExpiry: 7776000000000000
      responses:
        "200":
          description: Updated CA config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      caConfig:
                        type: object
                        description: Docker swarm.CAConfig object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/encryption:
    patch:
      operationId: patchSwarmEncryption
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Patch swarm encryption config
      description: Toggles Raft data-at-rest encryption. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              required: [AutoLockManagers]
              properties:
                AutoLockManagers:
                  type: boolean
            example:
              AutoLockManagers: true
      responses:
        "200":
          description: Updated encryption config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      encryption:
                        type: object
                        description: Docker swarm.EncryptionConfig object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/rotate-token:
    post:
      operationId: rotateToken
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Rotate join token
      description: Rotates the worker or manager join token. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              properties:
                target:
                  type: string
                  enum: [worker, manager]
                  description: Which join token to rotate
            example:
              target: worker
      responses:
        "204":
          description: Token rotated
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/rotate-unlock-key:
    post:
      operationId: rotateUnlockKey
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Rotate unlock key
      description: Rotates the swarm unlock key. Only works when autolock is enabled. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "204":
          description: Unlock key rotated. Retrieve the new key via GET /swarm/unlock-key.
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/force-rotate-ca:
    post:
      operationId: forceRotateCA
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Force CA certificate rotation
      description: Forces rotation of the cluster root CA certificate. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "204":
          description: CA rotation initiated
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /swarm/plugins:
    get:
      operationId: getSwarmPlugins
      tags: [Cluster]
      summary: Installed Docker plugins, beside the swarm's own configuration
      description: >
        An alias of `GET /plugins`, serving the identical response. It exists so
        the dashboard's swarm page can read the plugin list from the path it
        already sits under.
      responses:
        "200":
          description: Plugin list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PluginCollection"

  /swarm/unlock-key:
    get:
      operationId: getUnlockKey
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Get swarm unlock key
      description: Returns the current swarm unlock key. Only available when autolock is enabled.
      responses:
        "200":
          description: Current unlock key
          headers:
            Cache-Control:
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      unlockKey:
                        type: string
              example:
                "@context": /api/context.jsonld
                "@id": /swarm/unlock-key
                "@type": SwarmUnlockKey
                unlockKey: SWMKEY-1-kX9P2q3r4s5t6u7v8w9x0y1z2A3B4C5D6E7F8G9H
        "500":
          description: Failed to retrieve unlock key

  /swarm/unlock:
    post:
      operationId: unlockSwarm
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Cluster]
      summary: Unlock an encrypted swarm
      description: |
        Unlocks a swarm that has been locked with autolock. The caller must provide
        the current unlock key. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [unlockKey]
              properties:
                unlockKey:
                  type: string
                  description: Current swarm unlock key
            example:
              unlockKey: SWMKEY-1-kX9P2q3r4s5t6u7v8w9x0y1z2A3B4C5D6E7F8G9H
      responses:
        "204":
          description: Swarm unlocked
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "500":
          description: Failed to unlock swarm

  /tokens/{subject}:
    delete:
      operationId: revokeSubjectTokens
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Authentication]
      summary: Revoke a user's issued tokens
      description: |
        Withdraws every client the subject authorized through the authorization
        server: their refresh tokens stop working, and their remembered approvals
        are forgotten, so a client can't re-authorize without the consent screen.
        Access tokens already issued run out their `oauth.access_token_ttl`, and
        streams opened with them close then. Requires operations level 3 and write
        on `swarm:cluster`. Answers 404 when the authorization server is off.
      parameters:
        - name: subject
          in: path
          required: true
          description: The subject the tokens were issued to, as `GET /auth/whoami` reports it
          schema:
            type: string
          example: alice@example.com
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "204":
          description: Revoked. A subject with nothing to revoke also answers 204.
          headers:
            Revoked-Grants:
              description: How many grant families were revoked
              schema:
                type: integer
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /disk-usage:
    get:
      operationId: getDiskUsage
      tags: [Cluster]
      summary: Docker disk usage
      description: >-
        Summarized disk usage by category (images, containers, volumes, build cache).
        The result is shared between callers and can be up to 30 seconds old.
      responses:
        "200":
          description: Disk usage summaries
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiskUsageCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /disk-usage/images
                    "@type": DiskUsageSummary
                    type: images
                    count: 24
                    active: 18
                    totalSize: 4823691264
                    reclaimable: 1124532224
                  - "@id": /disk-usage/containers
                    "@type": DiskUsageSummary
                    type: containers
                    count: 12
                    active: 12
                    totalSize: 234881024
                    reclaimable: 0
                  - "@id": /disk-usage/volumes
                    "@type": DiskUsageSummary
                    type: volumes
                    count: 8
                    active: 6
                    totalSize: 1572864000
                    reclaimable: 419430400
                  - "@id": /disk-usage/buildCache
                    "@type": DiskUsageSummary
                    type: buildCache
                    count: 47
                    active: 0
                    totalSize: 312557568
                    reclaimable: 312557568
                total: 4
                limit: 4
                offset: 0
        "304":
          $ref: "#/components/responses/NotModified"

  /plugins:
    get:
      operationId: getPlugins
      tags: [Cluster]
      summary: Installed Docker plugins
      responses:
        "200":
          description: Plugin list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PluginCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /plugins/vieux/sshfs:latest
                    "@type": Plugin
                    Id: 5724e2c8d8d7
                    Name: vieux/sshfs:latest
                    Enabled: true
                    PluginReference: docker.io/vieux/sshfs:latest@sha256:1234abcd
                    Settings:
                      Args: []
                      Env: ["DEBUG=0"]
                      Mounts: []
                      Devices: []
                  - "@id": /plugins/rexray/ebs:latest
                    "@type": Plugin
                    Id: 8b9c1d2e3f4a
                    Name: rexray/ebs:latest
                    Enabled: false
                    PluginReference: docker.io/rexray/ebs:latest@sha256:5678efgh
                    Settings:
                      Args: []
                      Env: ["EBS_REGION=us-east-1"]
                      Mounts: []
                      Devices: []
                total: 2
                limit: 2
                offset: 0
    post:
      operationId: installPlugin
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Plugins]
      summary: Install a plugin
      description: |
        Installs a plugin from a remote registry. Requires operations level 3.
        Returns the installed plugin detail as a JSON-LD envelope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [remote, privileges]
              properties:
                remote:
                  type: string
                  description: Plugin reference (e.g. "vieux/sshfs:latest")
                privileges:
                  type: array
                  description: >
                    The privileges the caller reviewed and grants, as
                    POST /plugins/privileges returned them. The install is
                    refused with 409 (PLG006) if the plugin now requires a
                    different set; send [] for a plugin that requires none.
                  items:
                    type: object
                    properties:
                      Name: {type: string}
                      Description: {type: string}
                      Value: {type: array, items: {type: string}}
            example:
              remote: vieux/sshfs:latest
              privileges:
                - Name: network
                  Description: permissions to access a network
                  Value: [host]
      responses:
        "201":
          description: Plugin installed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      plugin:
                        type: object
                        description: Docker Plugin object
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The plugin requires different privileges from the ones granted (PLG006)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

  /plugins/privileges:
    post:
      operationId: getPluginPrivileges
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Plugins]
      summary: Get plugin privileges
      description: |
        Returns the list of privileges required by a remote plugin image.
        Used before installing a plugin to review requested permissions.
        Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [remote]
              properties:
                remote:
                  type: string
                  description: Plugin reference (e.g. "vieux/sshfs:latest")
            example:
              remote: vieux/sshfs:latest
      responses:
        "200":
          description: Plugin privileges
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      privileges:
                        type: array
                        items:
                          type: object
                        description: List of requested plugin privileges
              example:
                "@context": /api/context.jsonld
                "@id": /plugins/privileges
                "@type": PluginPrivileges
                privileges:
                  - Name: network
                    Description: permissions to access a network
                    Value: [host]
                  - Name: mount
                    Description: host path to mount
                    Value: [""]
                  - Name: device
                    Description: host device to access
                    Value: ["/dev/fuse"]
                  - Name: capabilities
                    Description: list of additional capabilities required
                    Value: ["CAP_SYS_ADMIN"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /plugins/{name}:
    get:
      operationId: getPlugin
      tags: [Plugins]
      summary: Plugin detail
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
      responses:
        "200":
          description: Plugin detail
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      plugin:
                        type: object
                        description: Docker Plugin object
              example:
                "@context": /api/context.jsonld
                "@id": /plugins/vieux/sshfs:latest
                "@type": Plugin
                plugin:
                  Id: 5724e2c8d8d7
                  Name: vieux/sshfs:latest
                  Enabled: true
                  PluginReference: docker.io/vieux/sshfs:latest@sha256:1234abcd
                  Config:
                    Description: sshFS plugin for Docker
                    Documentation: https://docs.docker.com/engine/extend/plugins/
                    Interface:
                      Types: [docker.volumedriver/1.0]
                      Socket: sshfs.sock
                    Entrypoint: [/docker-volume-sshfs]
                    Env:
                      - Name: DEBUG
                        Description: Enable debug logging
                        Settable: [value]
                        Value: "0"
                  Settings:
                    Args: []
                    Env: ["DEBUG=0"]
                    Mounts: []
                    Devices: []
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removePlugin
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Plugins]
      summary: Remove a plugin
      description: |
        Removes an installed plugin. Use `?force=true` to force removal
        even if the plugin is in use. Requires operations level 3.
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
        - name: force
          in: query
          description: Force removal even if the plugin is in use
          schema:
            type: boolean
            default: false
          example: false
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Plugin removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "503":
          $ref: "#/components/responses/BackendUnavailable"

  /plugins/{name}/enable:
    post:
      operationId: enablePlugin
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Plugins]
      summary: Enable a plugin
      description: Enables a disabled plugin. Requires operations level 2.
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "204":
          description: Plugin enabled
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /plugins/{name}/disable:
    post:
      operationId: disablePlugin
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Plugins]
      summary: Disable a plugin
      description: Disables an enabled plugin. Requires operations level 2.
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "204":
          description: Plugin disabled
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /plugins/{name}/upgrade:
    post:
      operationId: upgradePlugin
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Plugins]
      summary: Upgrade a plugin
      description: |
        Upgrades a plugin to a new remote image. Requires operations level 3.
        Returns the updated plugin detail, or 204 with `Prefer: return=minimal`.
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [remote, privileges]
              properties:
                remote:
                  type: string
                  description: New plugin reference
                privileges:
                  type: array
                  description: >
                    The privileges the caller reviewed and grants, as
                    POST /plugins/privileges returned them. The install is
                    refused with 409 (PLG006) if the plugin now requires a
                    different set; send [] for a plugin that requires none.
                  items:
                    type: object
                    properties:
                      Name: {type: string}
                      Description: {type: string}
                      Value: {type: array, items: {type: string}}
            example:
              remote: vieux/sshfs:v1.2.0
              privileges: []
      responses:
        "200":
          description: Upgraded plugin detail
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      plugin:
                        type: object
                        description: Docker Plugin object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The plugin requires different privileges from the ones granted (PLG006)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /plugins/{name}/settings:
    patch:
      operationId: configurePlugin
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Plugins]
      summary: Configure plugin settings
      description: |
        Updates plugin args and environment variables. Requires operations level 2.
        Returns the updated plugin detail, or 204 with `Prefer: return=minimal`.
      parameters:
        - name: name
          in: path
          required: true
          description: Plugin name
          schema:
            type: string
          example: vieux/sshfs:latest
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                args:
                  type: array
                  items:
                    type: string
                  description: Plugin args to set
                env:
                  type: array
                  items:
                    type: string
                  description: Plugin env vars to set
            example:
              env: ["DEBUG=1", "EBS_REGION=eu-west-1"]
      responses:
        "200":
          description: Updated plugin detail
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      plugin:
                        type: object
                        description: Docker Plugin object
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Nodes ────────────────────────────────────────────────────────────
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /nodes:
    get:
      operationId: listNodes
      tags: [Nodes]
      summary: List nodes
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [hostname, role, status, availability]
          example: hostname
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated node list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NodeCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /nodes/qz7k3v9xm2p4
                    "@type": Node
                    ID: qz7k3v9xm2p4
                    Version: { Index: 42 }
                    CreatedAt: "2025-01-15T10:30:00Z"
                    Spec:
                      Name: node-1
                      Role: manager
                      Availability: active
                    Status:
                      State: ready
                      Addr: 10.0.0.2
                    Description:
                      Hostname: node-1
                      Platform: { Architecture: x86_64, OS: linux }
                      Resources: { NanoCPUs: 4000000000, MemoryBytes: 8589934592 }
                    ManagerStatus:
                      Leader: true
                      Reachability: reachable
                      Addr: 10.0.0.2:2377
                total: 3
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NodeCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /nodes/{id}:
    get:
      operationId: getNode
      tags: [Nodes]
      summary: Node detail
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Node detail
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      node:
                        type: object
                        description: Docker swarm.Node object
              example:
                "@context": /api/context.jsonld
                "@id": /nodes/qz7k3v9xm2p4
                "@type": Node
                node:
                  ID: qz7k3v9xm2p4
                  Version: { Index: 42 }
                  CreatedAt: "2025-01-15T10:30:00Z"
                  Spec:
                    Name: node-1
                    Role: manager
                    Availability: active
                    Labels:
                      region: us-east-1
                  Status:
                    State: ready
                    Addr: 10.0.0.2
                  Description:
                    Hostname: node-1
                    Platform: { Architecture: x86_64, OS: linux }
                    Resources: { NanoCPUs: 4000000000, MemoryBytes: 8589934592 }
                    Engine: { EngineVersion: "27.4.1" }
                  ManagerStatus:
                    Leader: true
                    Reachability: reachable
                    Addr: 10.0.0.2:2377
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeNode
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Nodes]
      summary: Remove a node
      description: |
        Removes a node from the swarm. The node must be down, and a manager must first be demoted
        to worker; otherwise the removal is refused with `409 NOD001`. `?force=true` removes a
        node that is still up, but does not override the refusal for a manager.
        Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - name: force
          in: query
          description: Remove the node even if it is not down. Does not override the refusal for a manager.
          schema:
            type: boolean
            default: false
          example: false
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Node removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /nodes/{id}/tasks:
    get:
      operationId: getNodeTasks
      tags: [Nodes]
      summary: Tasks running on a node
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Collection of enriched tasks
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /tasks/tsk_abc123def456
                    "@type": Task
                    ID: tsk_abc123def456
                    ServiceID: r8m2x5nk3p7q
                    ServiceName: webapp
                    NodeID: qz7k3v9xm2p4
                    NodeHostname: node-1
                    Slot: 1
                    Status:
                      State: running
                      Timestamp: "2026-04-13T08:12:34Z"
                      ContainerStatus: { ContainerID: c1d2e3f4a5b6 }
                    DesiredState: running
                  - "@id": /tasks/tsk_def456ghi789
                    "@type": Task
                    ID: tsk_def456ghi789
                    ServiceID: t9n3y6ol4q8r
                    ServiceName: api
                    NodeID: qz7k3v9xm2p4
                    NodeHostname: node-1
                    Slot: 2
                    Status:
                      State: running
                      Timestamp: "2026-04-13T08:12:35Z"
                      ContainerStatus: { ContainerID: d2e3f4a5b6c7 }
                    DesiredState: running
                total: 2
                limit: 2
                offset: 0
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"

  /nodes/{id}/drain-impact:
    get:
      operationId: getNodeDrainImpact
      tags: [Nodes]
      summary: What draining a node would move
      description: |
        Assesses a drain before it happens: every service with a task on the
        node, and whether another node could take it. `movable` services are
        joined by an edge to each node that satisfies their placement
        constraints and per-node replica cap; a `stranded` service has no
        edge, and its `detail` names what blocked it. A `global` service
        stops on the node rather than moving. Candidates are the other ready,
        active nodes the caller may read; when grants hide any, `note` says
        so. The same assessment as the MCP `get_topology` drain-impact view.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Drain-impact graph
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    required: [view, nodes, edges]
                    properties:
                      view:
                        type: string
                        enum: [drain-impact]
                      subject:
                        type: string
                        description: Hostname of the node assessed
                      note:
                        type: string
                        description: Present when grants hid nodes from the assessment
                      nodes:
                        type: array
                        items:
                          type: object
                          required: [id, label, type]
                          properties:
                            id:
                              type: string
                            label:
                              type: string
                            type:
                              type: string
                              enum: [service, node]
                            group:
                              type: string
                              description: Stack namespace, for a service
                            detail:
                              type: string
                              description: >-
                                For a service, how many tasks it has here and
                                where they can go, or what strands them; for a
                                node, its role.
                            state:
                              type: string
                              description: >-
                                `movable`, `stranded` or `global` for a
                                service; the node state for a node.
                      edges:
                        type: array
                        items:
                          type: object
                          required: [source, target]
                          properties:
                            source:
                              type: string
                              description: Service ID
                            target:
                              type: string
                              description: ID of a node that can host it
                            label:
                              type: string
              example:
                "@context": /api/context.jsonld
                "@id": /nodes/qz7k3v9xm2p4/drain-impact
                "@type": DrainImpact
                view: drain-impact
                subject: node-1
                nodes:
                  - id: wv2l8p1sn6c4
                    label: node-2
                    type: node
                    detail: worker
                    state: ready
                  - id: r8m2x5nk3p7q
                    label: webapp
                    type: service
                    detail: "1 task(s) here; 1 node(s) can take them"
                    state: movable
                  - id: t9n3y6ol4q8r
                    label: gpu-worker
                    type: service
                    detail: node.labels.gpu==true
                    state: stranded
                edges:
                  - source: r8m2x5nk3p7q
                    target: wv2l8p1sn6c4
                    label: can host
        "304":
          $ref: "#/components/responses/NotModified"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /nodes/{id}/availability:
    put:
      operationId: updateNodeAvailability
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Nodes]
      summary: Update node availability
      description: Sets the availability of a node (active, drain, or pause).
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [availability]
              properties:
                availability:
                  type: string
                  enum: [active, drain, pause]
                  x-enum-descriptions:
                    - Node accepts new tasks (normal operation)
                    - Node refuses new tasks and existing tasks are rescheduled elsewhere
                    - Node refuses new tasks but existing tasks keep running
                  description: New availability state for the node
            example:
              availability: drain
      responses:
        "200":
          description: Updated node
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      node:
                        type: object
                        description: Docker swarm.Node object
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /nodes/{id}/labels:
    get:
      operationId: getNodeLabels
      tags: [Nodes]
      summary: Get node labels
      description: Returns the current labels of the node as a key/value map.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Node labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
              example:
                "@context": /api/context.jsonld
                "@id": /nodes/qz7k3v9xm2p4/labels
                "@type": NodeLabels
                labels:
                  region: us-east-1
                  env: production
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchNodeLabels
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Nodes]
      summary: Patch node labels
      description: |
        Applies a JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) to the node labels.
        Requires `Content-Type: application/json-patch+json` or `application/merge-patch+json`.

        Requires operations level 2 — one below the other node writes. Relabelling changes where
        tasks may be placed; draining a node or demoting it from manager changes whether the swarm
        stays healthy, so those stay at level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json-patch+json:
            schema:
              $ref: "#/components/schemas/JSONPatch"
            example:
              - op: add
                path: /env
                value: staging
              - op: remove
                path: /deprecated-label
          application/merge-patch+json:
            schema:
              type: object
              additionalProperties:
                type: [string, "null"]
              description: Label keys to set (string value) or remove (null value)
            example:
              env: staging
              deprecated-label: null
      responses:
        "200":
          description: Updated node labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /nodes/{id}/role:
    get:
      operationId: getNodeRole
      tags: [Nodes]
      summary: Get node role
      description: Returns the current role of the node (worker or manager).
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Node role
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      role:
                        type: string
                        enum: [worker, manager]
                      isLeader:
                        type: boolean
                      managerCount:
                        type: integer
              example:
                "@context": /api/context.jsonld
                "@id": /nodes/qz7k3v9xm2p4/role
                "@type": NodeRole
                role: manager
                isLeader: true
                managerCount: 3
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: updateNodeRole
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Nodes]
      summary: Update node role
      description: Promotes or demotes a node. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [worker, manager]
                  x-enum-descriptions:
                    - Plain worker node (runs tasks, not part of raft quorum)
                    - Manager node (runs tasks and participates in raft consensus)
                  description: New role for the node
            example:
              role: manager
      responses:
        "200":
          description: Updated node
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      node:
                        type: object
                        description: Docker swarm.Node object
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  # ── Services ─────────────────────────────────────────────────────────

  /services:
    get:
      operationId: listServices
      tags: [Services]
      summary: List services
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name, mode]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated service list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceCollection"
              examples:
                populated:
                  summary: Cluster with deployed services
                  value:
                    "@context": /api/context.jsonld
                    "@type": Collection
                    items:
                      - "@id": /services/r8m2x5nk3p7q
                        "@type": Service
                        ID: r8m2x5nk3p7q
                        Version: { Index: 156 }
                        Spec:
                          Name: webapp
                          Labels:
                            com.docker.stack.namespace: myapp
                          TaskTemplate:
                            ContainerSpec:
                              Image: nginx:1.27@sha256:abc123...
                            Resources:
                              Limits: { NanoCPUs: 1000000000, MemoryBytes: 536870912 }
                          Mode:
                            Replicated: { Replicas: 3 }
                        RunningTasks: 3
                    total: 12
                    limit: 50
                    offset: 0
                empty:
                  summary: No services deployed
                  value:
                    "@context": /api/context.jsonld
                    "@type": Collection
                    items: []
                    total: 0
                    limit: 50
                    offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /services/{id}:
    get:
      operationId: getService
      tags: [Services]
      summary: Service detail
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Service detail
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q
                "@type": Service
                service:
                  ID: r8m2x5nk3p7q
                  Version: { Index: 156 }
                  Spec:
                    Name: webapp
                    Labels:
                      com.docker.stack.namespace: myapp
                    TaskTemplate:
                      ContainerSpec:
                        Image: nginx:1.27@sha256:abc123...
                        Env: [NODE_ENV=production]
                        Healthcheck:
                          Test: [CMD, curl, -f, http://localhost/]
                          Interval: 30000000000
                          Timeout: 5000000000
                          Retries: 3
                      Resources:
                        Limits: { NanoCPUs: 1000000000, MemoryBytes: 536870912 }
                        Reservations: { NanoCPUs: 500000000, MemoryBytes: 268435456 }
                      RestartPolicy:
                        Condition: on-failure
                        MaxAttempts: 3
                    Mode:
                      Replicated: { Replicas: 3 }
                    UpdateConfig:
                      Parallelism: 1
                      Delay: 10000000000
                      FailureAction: rollback
                    EndpointSpec:
                      Ports:
                        - Protocol: tcp
                          TargetPort: 80
                          PublishedPort: 8080
                          PublishMode: ingress
                  Endpoint:
                    Ports:
                      - Protocol: tcp
                        TargetPort: 80
                        PublishedPort: 8080
                        PublishMode: ingress
                integrations: []
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            application/yaml:
              schema:
                $ref: "#/components/schemas/ComposeDocument"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeService
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Services]
      summary: Remove a service
      description: Removes a service from the swarm. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Service removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/tasks:
    get:
      operationId: getServiceTasks
      tags: [Services]
      summary: Tasks belonging to a service
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Collection of enriched tasks
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /tasks/tsk_abc123def456
                    "@type": Task
                    ID: tsk_abc123def456
                    ServiceID: r8m2x5nk3p7q
                    ServiceName: webapp
                    NodeID: qz7k3v9xm2p4
                    NodeHostname: node-1
                    Slot: 1
                    Status: { State: running, Timestamp: "2026-04-13T08:12:34Z" }
                    DesiredState: running
                  - "@id": /tasks/tsk_ghi789jkl012
                    "@type": Task
                    ID: tsk_ghi789jkl012
                    ServiceID: r8m2x5nk3p7q
                    ServiceName: webapp
                    NodeID: e9f0g1h2i3j4
                    NodeHostname: node-2
                    Slot: 2
                    Status: { State: running, Timestamp: "2026-04-13T08:12:36Z" }
                    DesiredState: running
                  - "@id": /tasks/tsk_mno345pqr678
                    "@type": Task
                    ID: tsk_mno345pqr678
                    ServiceID: r8m2x5nk3p7q
                    ServiceName: webapp
                    NodeID: y3z4a5b6c7d8
                    NodeHostname: node-3
                    Slot: 3
                    Status: { State: running, Timestamp: "2026-04-13T08:12:37Z" }
                    DesiredState: running
                total: 3
                limit: 3
                offset: 0
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"

  /services/{id}/logs:
    get:
      operationId: getServiceLogs
      tags: [Services]
      summary: Service logs
      description: |
        JSON (default): returns paginated log lines.
        SSE (`Accept: text/event-stream`): streams log lines in real time.

        SSE streams have a 128-connection limit (`LOG001`) and JSON reads a
        32-read limit (`LOG009`); past either, the request returns 429.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/logLimit"
        - $ref: "#/components/parameters/logAfter"
        - $ref: "#/components/parameters/logBefore"
        - $ref: "#/components/parameters/logStream"
      responses:
        "200":
          description: Log lines
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LogResponse"
            text/event-stream:
              schema:
                $ref: "#/components/schemas/LogLine"
              example:
                timestamp: "2026-04-13T08:12:34.123456789Z"
                message: 172.18.0.1 - - [13/Apr/2026:08:12:34 +0000] "GET / HTTP/1.1" 200 612
                stream: stdout
                attrs:
                  nodeId: qz7k3v9xm2p4
                  serviceId: r8m2x5nk3p7q
                  taskId: tsk_abc123def456
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /services/{id}/scale:
    put:
      operationId: scaleService
      x-badges:
        - name: "operations-level:1"
          position: after
          color: green
      tags: [Services]
      summary: Scale a service
      description: Sets the replica count for a replicated service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/Prefer"
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [replicas]
              properties:
                replicas:
                  type: integer
                  minimum: 0
                  description: Desired number of replicas
            example:
              replicas: 5
      responses:
        "200":
          description: Updated service
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
        "202":
          $ref: "#/components/responses/ServiceAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/image:
    put:
      operationId: updateServiceImage
      x-badges:
        - name: "operations-level:1"
          position: after
          color: green
      tags: [Services]
      summary: Update service image
      description: Updates the container image for a service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/Prefer"
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image]
              properties:
                image:
                  type: string
                  description: New container image reference
                  example: nginx:latest
            example:
              image: nginx:1.28
      responses:
        "200":
          description: Updated service
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
        "202":
          $ref: "#/components/responses/ServiceAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/rollback:
    post:
      operationId: rollbackService
      x-badges:
        - name: "operations-level:1"
          position: after
          color: green
      tags: [Services]
      summary: Rollback a service
      description: Rolls back the service to its previous specification.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/Prefer"
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "200":
          description: Service rollback initiated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q
                "@type": Service
                service:
                  ID: r8m2x5nk3p7q
                  Version: { Index: 163 }
                  Spec:
                    Name: webapp
                    TaskTemplate:
                      ContainerSpec:
                        Image: nginx:1.27@sha256:abc123...
                    Mode:
                      Replicated: { Replicas: 3 }
                  UpdateStatus:
                    State: rollback_started
                    StartedAt: "2026-04-13T08:15:00Z"
                    Message: rollback initiated
                integrations: []
        "202":
          $ref: "#/components/responses/ServiceAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/restart:
    post:
      operationId: restartService
      x-badges:
        - name: "operations-level:1"
          position: after
          color: green
      tags: [Services]
      summary: Restart a service
      description: Forces a re-deploy of the service by incrementing its ForceUpdate counter.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/Prefer"
        - $ref: "#/components/parameters/IfMatchUnrepresented"
      responses:
        "200":
          description: Service restart initiated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q
                "@type": Service
                service:
                  ID: r8m2x5nk3p7q
                  Version: { Index: 157 }
                  Spec:
                    Name: webapp
                    TaskTemplate:
                      ContainerSpec:
                        Image: nginx:1.28@sha256:def456...
                      ForceUpdate: 1
                    Mode:
                      Replicated: { Replicas: 3 }
                integrations: []
        "202":
          $ref: "#/components/responses/ServiceAccepted"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/env:
    get:
      operationId: getServiceEnv
      tags: [Services]
      summary: Get service environment variables
      description: Returns the current environment variables of the service as a key/value map.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Environment variables
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      env:
                        type: object
                        additionalProperties:
                          type: string
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q/env
                "@type": ServiceEnv
                env:
                  NODE_ENV: production
                  DATABASE_URL: postgres://db:5432/app
                  LOG_LEVEL: info
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceEnv
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service environment variables
      description: |
        Applies a JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) to the service
        environment variables. Requires `Content-Type: application/json-patch+json` or
        `application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json-patch+json:
            schema:
              $ref: "#/components/schemas/JSONPatch"
            example:
              - op: add
                path: /LOG_LEVEL
                value: debug
              - op: replace
                path: /NODE_ENV
                value: staging
          application/merge-patch+json:
            schema:
              type: object
              additionalProperties:
                type: [string, "null"]
              description: Environment variables to set (string value) or remove (null value)
            example:
              LOG_LEVEL: debug
              NODE_ENV: staging
              DEPRECATED_VAR: null
      responses:
        "200":
          description: Updated environment variables
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      env:
                        type: object
                        additionalProperties:
                          type: string
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/resources:
    get:
      operationId: getServiceResources
      tags: [Services]
      summary: Get service resource requirements
      description: Returns the CPU and memory reservations and limits for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Resource requirements
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceResources"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceResources
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service resource requirements
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service resource requirements.
        Requires `Content-Type: application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              $ref: "#/components/schemas/ResourceRequirements"
            example:
              Limits:
                NanoCPUs: 2000000000
                MemoryBytes: 536870912
              Reservations:
                NanoCPUs: 500000000
                MemoryBytes: 268435456
      responses:
        "200":
          description: Updated resource requirements
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceResources"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/placement:
    get:
      operationId: getServicePlacement
      tags: [Services]
      summary: Get service placement constraints
      description: Returns the placement constraints and preferences for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Placement constraints and preferences
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServicePlacement"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: putServicePlacement
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Update service placement constraints
      description: Replaces the placement constraints and preferences for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Docker swarm.Placement object
            example:
              Constraints:
                - node.role == worker
                - node.labels.region == us-east-1
              MaxReplicas: 2
      responses:
        "200":
          description: Updated placement constraints and preferences
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServicePlacement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/ports:
    get:
      operationId: getServicePorts
      tags: [Services]
      summary: Get service port bindings
      description: Returns the published port configurations for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Port bindings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServicePorts"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServicePorts
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service port bindings
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service port bindings.
        Requires `Content-Type: application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              $ref: "#/components/schemas/ServicePorts"
            example:
              ports:
                - Protocol: tcp
                  TargetPort: 8080
                  PublishedPort: 80
                  PublishMode: ingress
                - Protocol: tcp
                  TargetPort: 9090
                  PublishedPort: 9090
                  PublishMode: host
      responses:
        "200":
          description: Updated port bindings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServicePorts"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/update-policy:
    get:
      operationId: getServiceUpdatePolicy
      tags: [Services]
      summary: Get service update policy
      description: Returns the rolling update configuration for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Update policy
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceUpdatePolicy"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceUpdatePolicy
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service update policy
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service update policy.
        Requires `Content-Type: application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              $ref: "#/components/schemas/UpdateConfig"
            example:
              Parallelism: 2
              Delay: 10000000000
              FailureAction: rollback
              Order: start-first
      responses:
        "200":
          description: Updated update policy
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceUpdatePolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/rollback-policy:
    get:
      operationId: getServiceRollbackPolicy
      tags: [Services]
      summary: Get service rollback policy
      description: Returns the rollback configuration for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Rollback policy
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceRollbackPolicy"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceRollbackPolicy
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service rollback policy
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service rollback policy.
        Requires `Content-Type: application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              $ref: "#/components/schemas/UpdateConfig"
            example:
              Parallelism: 1
              Delay: 5000000000
              FailureAction: pause
              Order: stop-first
      responses:
        "200":
          description: Updated rollback policy
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceRollbackPolicy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/log-driver:
    get:
      operationId: getServiceLogDriver
      tags: [Services]
      summary: Get service log driver
      description: Returns the log driver name and options configured for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Log driver configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceLogDriver"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceLogDriver
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service log driver
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service log driver configuration.
        Requires `Content-Type: application/merge-patch+json`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              description: Docker swarm.Driver fields to merge
            example:
              Name: json-file
              Options:
                max-size: 10m
                max-file: "3"
      responses:
        "200":
          description: Updated log driver configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - $ref: "#/components/schemas/ServiceLogDriver"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/labels:
    get:
      operationId: getServiceLabels
      tags: [Services]
      summary: Get service labels
      description: Returns the current labels of the service as a key/value map.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Service labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceLabels
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service labels
      description: |
        Applies a JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) to the service labels.
        Requires `Content-Type: application/json-patch+json` or `application/merge-patch+json`.
        Requires operations level 2. Setting `com.docker.stack.namespace` to a stack the service
        is not in also requires `write` on that stack (`403 ACL002` otherwise).
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json-patch+json:
            schema:
              $ref: "#/components/schemas/JSONPatch"
            example:
              - op: add
                path: /team
                value: backend
              - op: remove
                path: /deprecated
          application/merge-patch+json:
            schema:
              type: object
              additionalProperties:
                type: [string, "null"]
              description: Label keys to set (string value) or remove (null value)
            example:
              team: backend
              deprecated: null
      responses:
        "200":
          description: Updated service labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/healthcheck:
    get:
      operationId: getServiceHealthcheck
      tags: [Services]
      summary: Get service healthcheck config
      description: Returns the healthcheck configuration for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Healthcheck configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      healthcheck:
                        type: [object, "null"]
                        description: Docker container.HealthConfig object
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: putServiceHealthcheck
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Replace service healthcheck config
      description: Replaces the healthcheck configuration for the service. Requires operations level 2.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Docker container.HealthConfig object
            example:
              Test: [CMD, curl, -f, http://localhost/health]
              Interval: 30000000000
              Timeout: 5000000000
              Retries: 3
              StartPeriod: 10000000000
      responses:
        "200":
          description: Updated healthcheck configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      healthcheck:
                        type: object
                        description: Docker container.HealthConfig object
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
    patch:
      operationId: patchServiceHealthcheck
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service healthcheck config
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service healthcheck configuration.
        Requires `Content-Type: application/merge-patch+json`. Requires operations level 2.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
              description: Docker container.HealthConfig fields to merge
            example:
              Interval: 60000000000
              Retries: 5
      responses:
        "200":
          description: Updated healthcheck configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      healthcheck:
                        type: object
                        description: Docker container.HealthConfig object
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/configs:
    get:
      operationId: getServiceConfigs
      tags: [Services]
      summary: Get service config references
      description: Returns the config references attached to the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Config references
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      configs:
                        type: array
                        items:
                          type: object
                          properties:
                            configID:
                              type: string
                            configName:
                              type: string
                            fileName:
                              type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceConfigs
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service config references
      description: >-
        Replaces the config references for the service. Requires operations level 2, and read access to
        each config the service does not already carry. A config ID that is not known answers
        `400 SVC021`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                configs:
                  type: array
                  description: Replacement set of config references for the service
                  items:
                    type: object
                    properties:
                      configID:
                        type: string
                      configName:
                        type: string
                        description: >-
                          Required, but not trusted: the name recorded on the service, and the default
                          `fileName`, come from the config `configID` names.
                      fileName:
                        type: string
            example:
              configs:
                - configID: cfg_abc123
                  configName: app.conf
                  fileName: /etc/app/config.json
      responses:
        "200":
          description: Updated config references
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      configs:
                        type: array
                        items:
                          type: object
                          properties:
                            configID:
                              type: string
                            configName:
                              type: string
                            fileName:
                              type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/secrets:
    get:
      operationId: getServiceSecrets
      tags: [Services]
      summary: Get service secret references
      description: Returns the secret references attached to the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Secret references
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      secrets:
                        type: array
                        items:
                          type: object
                          properties:
                            secretID:
                              type: string
                            secretName:
                              type: string
                            fileName:
                              type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceSecrets
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service secret references
      description: >-
        Replaces the secret references for the service. Requires operations level 2, and read access to
        each secret the service does not already carry. A secret ID that is not known answers
        `400 SVC021`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                secrets:
                  type: array
                  description: Replacement set of secret references for the service
                  items:
                    type: object
                    properties:
                      secretID:
                        type: string
                      secretName:
                        type: string
                        description: >-
                          Required, but not trusted: the name recorded on the service, and the default
                          `fileName` under `/run/secrets/`, come from the secret `secretID` names.
                      fileName:
                        type: string
            example:
              secrets:
                - secretID: sec_def456
                  secretName: db_password
                  fileName: /run/secrets/db_password
      responses:
        "200":
          description: Updated secret references
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      secrets:
                        type: array
                        items:
                          type: object
                          properties:
                            secretID:
                              type: string
                            secretName:
                              type: string
                            fileName:
                              type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/networks:
    get:
      operationId: getServiceNetworks
      tags: [Services]
      summary: Get service network attachments
      description: Returns the network attachments for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Network attachments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      networks:
                        type: array
                        items:
                          type: object
                          properties:
                            target:
                              type: string
                            aliases:
                              type: array
                              items:
                                type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceNetworks
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service network attachments
      description: >-
        Replaces the network attachments for the service. Requires operations level 2, and read access to
        each network the service does not already join. A network that is not known answers
        `400 SVC021`; a name several networks share answers `409 API015`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                networks:
                  type: array
                  description: Replacement set of network attachments for the service
                  items:
                    type: object
                    properties:
                      target:
                        type: string
                        description: Network ID or name; stored as the ID.
                      aliases:
                        type: array
                        items:
                          type: string
            example:
              networks:
                - target: net_ghi789
                  aliases: [webapp]
      responses:
        "200":
          description: Updated network attachments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      networks:
                        type: array
                        items:
                          type: object
                          properties:
                            target:
                              type: string
                            aliases:
                              type: array
                              items:
                                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/mounts:
    get:
      operationId: getServiceMounts
      tags: [Services]
      summary: Get service mount configuration
      description: Returns the mount configuration for the service.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Mount configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      mounts:
                        type: array
                        items:
                          type: object
                          description: Docker mount.Mount object
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceMounts
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service mount configuration
      description: Replaces the mount configuration for the service. Requires operations level 2, and level 3 to add a bind, named-pipe, or driver-configured volume mount the service does not already have.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mounts:
                  type: array
                  description: Replacement set of mounts for the service
                  items:
                    type: object
                    description: Docker mount.Mount object
            example:
              mounts:
                - Type: volume
                  Source: myapp_data
                  Target: /data
                  ReadOnly: false
      responses:
        "200":
          description: Updated mount configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      mounts:
                        type: array
                        items:
                          type: object
                          description: Docker mount.Mount object
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /services/{id}/container-config:
    get:
      operationId: getServiceContainerConfig
      tags: [Services]
      summary: Get service container config
      description: Returns the container-level configuration (hostname, user, working dir, args, etc.).
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Container configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      containerConfig:
                        $ref: "#/components/schemas/ContainerConfig"
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q/container-config
                "@type": ServiceContainerConfig
                containerConfig:
                  command: ["/usr/local/bin/server"]
                  args: ["--config", "/etc/app/config.toml"]
                  dir: /app
                  user: "1000:1000"
                  hostname: webapp
                  init: true
                  tty: false
                  readOnly: true
                  stopSignal: SIGTERM
                  stopGracePeriod: 10000000000
                  capabilityAdd: ["NET_BIND_SERVICE"]
                  capabilityDrop: ["ALL"]
                  groups: ["docker"]
                  hosts: ["db:10.0.0.5"]
                  dnsConfig:
                    nameservers: ["1.1.1.1", "1.0.0.1"]
                    search: ["internal.example.com"]
                    options: ["ndots:2"]
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchServiceContainerConfig
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Services]
      summary: Patch service container config
      description: |
        Applies a JSON Merge Patch (RFC 7396) to the service container configuration.
        Requires `Content-Type: application/merge-patch+json`. Requires operations level 2, and level 3 to add a capability to `capabilityAdd`.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              $ref: "#/components/schemas/ContainerConfig"
            example:
              dir: /srv
              stopGracePeriod: 30000000000
              readOnly: true
      responses:
        "200":
          description: Updated container configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      containerConfig:
                        $ref: "#/components/schemas/ContainerConfig"
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /services/{id}/mode:
    get:
      operationId: getServiceMode
      tags: [Services]
      summary: Get service mode
      description: Returns the current service mode (replicated or global) and replica count.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Service mode
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      mode:
                        type: string
                        enum: [replicated, global]
                      replicas:
                        type: [integer, "null"]
                        minimum: 0
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q/mode
                "@type": ServiceMode
                mode: replicated
                replicas: 3
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"

  /services/{id}/endpoint-mode:
    get:
      operationId: getServiceEndpointMode
      tags: [Services]
      summary: Get service endpoint mode
      description: Returns the current endpoint resolution mode (virtual IP or DNS round-robin).
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Service endpoint mode
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      endpointMode:
                        type: string
                        enum: ["", vip, dnsrr]
              example:
                "@context": /api/context.jsonld
                "@id": /services/r8m2x5nk3p7q/endpoint-mode
                "@type": ServiceEndpointMode
                endpointMode: vip
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: updateServiceEndpointMode
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Services]
      summary: Change service endpoint mode
      description: |
        Changes the service endpoint mode between vip and dnsrr. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/Prefer"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpointMode]
              properties:
                endpointMode:
                  type: string
                  enum: [vip, dnsrr]
                  x-enum-descriptions:
                    - Virtual IP-based load balancing (Swarm assigns a single VIP per service)
                    - DNS round-robin (clients resolve the service name to task IPs directly)
                  description: New endpoint resolution mode (virtual IP or DNS round-robin)
            example:
              endpointMode: vip
      responses:
        "200":
          description: Updated service
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceDetail"
        "202":
          $ref: "#/components/responses/ServiceAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  # ── Tasks ────────────────────────────────────────────────────────────

  /tasks:
    get:
      operationId: listTasks
      tags: [Tasks]
      summary: List tasks
      description: |
        Tasks are returned as EnrichedTask objects with ServiceName and
        NodeHostname populated from cache cross-references.
      parameters:
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [state, service, node]
          example: state
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated task list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /tasks/t7w2k9m4x1p6
                    "@type": Task
                    ID: t7w2k9m4x1p6
                    ServiceID: r8m2x5nk3p7q
                    NodeID: qz7k3v9xm2p4
                    Slot: 1
                    Status:
                      Timestamp: "2025-03-20T14:22:00Z"
                      State: running
                      ContainerStatus:
                        ContainerID: abc123def456
                    DesiredState: running
                    ServiceName: webapp
                    NodeHostname: node-1
                total: 42
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /tasks/{id}:
    get:
      operationId: getTask
      tags: [Tasks]
      summary: Task detail
      description: |
        Returns the enriched task with cross-references to its service and node.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Task detail with service and node refs
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      task:
                        $ref: "#/components/schemas/EnrichedTask"
                      service:
                        type: object
                        description: Cross-reference to the parent service
                        properties:
                          "@id":
                            type: string
                          name:
                            type: string
                      node:
                        type: object
                        description: Cross-reference to the assigned node
                        properties:
                          "@id":
                            type: string
                          hostname:
                            type: string
              example:
                "@context": /api/context.jsonld
                "@id": /tasks/t7w2k9m4x1p6
                "@type": Task
                task:
                  ID: t7w2k9m4x1p6
                  ServiceID: r8m2x5nk3p7q
                  NodeID: qz7k3v9xm2p4
                  Slot: 1
                  Status:
                    Timestamp: "2025-03-20T14:22:00Z"
                    State: running
                    ContainerStatus:
                      ContainerID: abc123def456
                      PID: 12345
                  DesiredState: running
                  ServiceName: webapp
                  NodeHostname: node-1
                service:
                  "@id": /services/r8m2x5nk3p7q
                  name: webapp
                node:
                  "@id": /nodes/qz7k3v9xm2p4
                  hostname: node-1
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeTask
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Tasks]
      summary: Force-remove a task
      description: |
        Forces removal of a task (equivalent to `docker rm -f` on the underlying container).
        Only works on tasks whose containers are still accessible on the node.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Task removed
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /tasks/{id}/logs:
    get:
      operationId: getTaskLogs
      tags: [Tasks]
      summary: Task logs
      description: |
        JSON (default): returns paginated log lines.
        SSE (`Accept: text/event-stream`): streams log lines in real time.

        SSE streams have a 128-connection limit (`LOG001`) and JSON reads a
        32-read limit (`LOG009`); past either, the request returns 429.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/logLimit"
        - $ref: "#/components/parameters/logAfter"
        - $ref: "#/components/parameters/logBefore"
        - $ref: "#/components/parameters/logStream"
      responses:
        "200":
          description: Log lines
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LogResponse"
            text/event-stream:
              schema:
                $ref: "#/components/schemas/LogLine"
              example:
                timestamp: "2026-04-13T08:12:34.123456789Z"
                message: starting server on :8080
                stream: stdout
                attrs:
                  nodeId: qz7k3v9xm2p4
                  serviceId: r8m2x5nk3p7q
                  taskId: tsk_abc123def456
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ── History ──────────────────────────────────────────────────────────

  /history:
    get:
      operationId: getHistory
      tags: [History]
      summary: Resource change history
      description: |
        Returns recent change events from the in-memory ring buffer (10,000 entries),
        newest first. Can be filtered by resource type, a specific resource and a
        name. A full page carries a `Link` with `rel="next"`, which pages by cursor.
      parameters:
        - name: type
          in: query
          description: Filter by resource type (node, service, task, etc.)
          schema:
            type: string
          example: service
        - name: resourceId
          in: query
          description: |
            Filter by a specific resource, named by its ID or its name. A name is
            resolved against `type` when one is given, and otherwise only when no
            other type claims it. An identifier matching nothing is used as it
            stands, so entries for a resource the cluster has since dropped stay
            reachable by the ID they were recorded under. A service's timeline
            includes its tasks' entries, with `type=service` as well.
          schema:
            type: string
          example: r8m2x5nk3p7q
        - $ref: "#/components/parameters/search"
        - name: before
          in: query
          description: Return entries older than this entry ID, as the `next` link sets it
          schema:
            type: integer
            minimum: 1
          example: 4182
        - name: limit
          in: query
          description: Max entries to return
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          example: 50
      responses:
        "200":
          description: History entries
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HistoryCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /history/1042
                    "@type": HistoryEntry
                    id: 1042
                    type: service
                    action: update
                    resourceId: r8m2x5nk3p7q
                    name: webapp
                    timestamp: "2025-03-20T14:30:00Z"
                    summary: Image updated to nginx:1.28
                  - "@id": /history/1041
                    "@type": HistoryEntry
                    id: 1041
                    type: task
                    action: update
                    resourceId: t7w2k9m4x1p6
                    name: "webapp.1"
                    timestamp: "2025-03-20T14:29:58Z"
                total: 1042
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  # ── Stacks ───────────────────────────────────────────────────────────

  /stacks:
    get:
      operationId: listStacks
      tags: [Stacks]
      summary: List stacks
      description: Stacks derived from the com.docker.stack.namespace label.
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated stack list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StackCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /stacks/myapp
                    "@type": Stack
                    name: myapp
                    services: [r8m2x5nk3p7q, j4n6w8t2k9m1]
                    configs: [cfg_abc123]
                    secrets: [sec_def456]
                    networks: [net_ghi789]
                    volumes: []
                total: 4
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StackCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /stacks/summary:
    get:
      operationId: getStackSummary
      tags: [Stacks]
      summary: Stack summaries with metrics
      description: |
        Returns all stacks with task counts and optional Prometheus metrics
        (memory usage, CPU usage per stack).
      responses:
        "200":
          description: Stack summaries
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StackSummaryCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /stacks/myapp
                    "@type": StackSummary
                    name: myapp
                    serviceCount: 4
                    configCount: 1
                    secretCount: 1
                    networkCount: 1
                    volumeCount: 0
                    desiredTasks: 12
                    tasksByState: { running: 11, shutdown: 1 }
                    updatingServices: 0
                    memoryLimitBytes: 2147483648
                    cpuLimitCores: 4.0
                    memoryUsageBytes: 1610612736
                    cpuUsagePercent: 35.2
                  - "@id": /stacks/monitoring
                    "@type": StackSummary
                    name: monitoring
                    serviceCount: 3
                    configCount: 0
                    secretCount: 0
                    networkCount: 1
                    volumeCount: 1
                    desiredTasks: 3
                    tasksByState: { running: 3 }
                    updatingServices: 0
                    memoryLimitBytes: 1073741824
                    cpuLimitCores: 2.0
                    memoryUsageBytes: 536870912
                    cpuUsagePercent: 4.5
                total: 2
                limit: 2
                offset: 0
        "304":
          $ref: "#/components/responses/NotModified"

  /stacks/{name}:
    get:
      operationId: getStack
      tags: [Stacks]
      summary: Stack detail
      description: Full stack with all its services, configs, secrets, networks, and volumes.
      parameters:
        - name: name
          in: path
          required: true
          description: Stack name
          schema:
            type: string
          example: myapp
      responses:
        "200":
          description: Stack detail
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      stack:
                        $ref: "#/components/schemas/StackDetail"
              example:
                "@context": /api/context.jsonld
                "@id": /stacks/myapp
                "@type": Stack
                stack:
                  name: myapp
                  services:
                    - ID: r8m2x5nk3p7q
                      Spec: { Name: webapp }
                  configs:
                    - ID: cfg_abc123
                      Spec: { Name: app.conf }
                  secrets:
                    - ID: sec_def456
                      Spec: { Name: db_password }
                  networks:
                    - Id: net_ghi789
                      Name: myapp_default
                  volumes: []
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            application/yaml:
              schema:
                $ref: "#/components/schemas/ComposeDocument"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeStack
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Stacks]
      summary: Remove a stack
      description: |
        Removes the stack's services, then its networks, secrets and configs, as
        `docker stack rm` does. Volumes are kept. A resource that is already gone
        is skipped; one that fails to go is reported under `errors` and does not
        stop the rest, so a 200 can still leave part of the stack behind.
        Requires operations level 3.
      parameters:
        - name: name
          in: path
          required: true
          description: Stack name
          schema:
            type: string
          example: myapp
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "200":
          description: What was removed, and what could not be
          content:
            application/json:
              schema:
                type: object
                required: [removed]
                properties:
                  removed:
                    type: object
                    description: How many of each type were removed
                    required: [services, networks, configs, secrets]
                    properties:
                      services: { type: integer }
                      networks: { type: integer }
                      configs: { type: integer }
                      secrets: { type: integer }
                  errors:
                    type: array
                    description: The resources that could not be removed. Absent when there are none.
                    items:
                      type: object
                      required: [type, id, error]
                      properties:
                        type:
                          type: string
                          enum: [service, network, secret, config]
                        id:
                          type: string
                        error:
                          type: string
              example:
                removed: { services: 2, networks: 1, configs: 1, secrets: 0 }
                errors:
                  - type: network
                    id: n4k8x2m9p3q7
                    error: network myapp_default has active endpoints
        "204":
          description: No content (Prefer return=minimal)
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  # ── Configs ──────────────────────────────────────────────────────────

  /configs:
    get:
      operationId: listConfigs
      tags: [Configs]
      summary: List configs
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name, created, updated]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated config list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConfigCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /configs/cfg_abc123
                    "@type": Config
                    ID: cfg_abc123
                    Version: { Index: 10 }
                    CreatedAt: "2025-02-01T09:00:00Z"
                    UpdatedAt: "2025-02-01T09:00:00Z"
                    Spec:
                      Name: app.conf
                      Labels:
                        com.docker.stack.namespace: myapp
                      Data: YXBwX2tleT12YWx1ZQ==
                total: 5
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConfigCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"
    post:
      operationId: createConfig
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Configs]
      summary: Create a config
      description: Creates a new config in the swarm. Requires operations level 2.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, data]
              properties:
                name:
                  type: string
                  description: Config name
                data:
                  type: string
                  format: byte
                  description: Config data (base64-encoded)
            example:
              name: nginx.conf
              data: c2VydmVyIHsgbGlzdGVuIDgwOyB9
      responses:
        "201":
          description: Config created
          headers:
            Location:
              schema:
                type: string
              description: Path to the created config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      config:
                        type: object
                        description: Docker swarm.Config object (data is base64-encoded)
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Config name conflict

  /configs/{id}:
    get:
      operationId: getConfig
      tags: [Configs]
      summary: Config detail
      description: |
        Returns the config with its data (base64-encoded) and
        a list of services using this config.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Config detail with cross-referenced services
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      config:
                        type: object
                        description: Docker swarm.Config object (data is base64-encoded)
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
              example:
                "@context": /api/context.jsonld
                "@id": /configs/cfg_abc123
                "@type": Config
                config:
                  ID: cfg_abc123
                  Version: { Index: 10 }
                  CreatedAt: "2025-02-01T09:00:00Z"
                  UpdatedAt: "2025-02-01T09:00:00Z"
                  Spec:
                    Name: app.conf
                    Labels:
                      com.docker.stack.namespace: myapp
                    Data: YXBwX2tleT12YWx1ZQ==
                services:
                  - "@id": /services/r8m2x5nk3p7q
                    id: r8m2x5nk3p7q
                    name: webapp
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeConfig
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Configs]
      summary: Remove a config
      description: Removes a config from the swarm. Fails if the config is in use by a service. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Config removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /configs/{id}/labels:
    get:
      operationId: getConfigLabels
      tags: [Configs]
      summary: Get config labels
      description: Returns the current labels of the config as a key/value map.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Config labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchConfigLabels
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Configs]
      summary: Patch config labels
      description: |
        Applies a JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) to the config labels.
        Requires `Content-Type: application/json-patch+json` or `application/merge-patch+json`.
        Setting `com.docker.stack.namespace` to a stack the config is not in also requires
        `write` on that stack (`403 ACL002` otherwise).
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json-patch+json:
            schema:
              $ref: "#/components/schemas/JSONPatch"
            example:
              - op: add
                path: /environment
                value: production
              - op: remove
                path: /draft
          application/merge-patch+json:
            schema:
              type: object
              additionalProperties:
                type: [string, "null"]
              description: Label keys to set (string value) or remove (null value)
            example:
              environment: production
              draft: null
      responses:
        "200":
          description: Updated config labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  # ── Secrets ──────────────────────────────────────────────────────────

  /secrets:
    get:
      operationId: listSecrets
      tags: [Secrets]
      summary: List secrets
      description: Secret data is always redacted (cleared before response).
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name, created, updated]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated secret list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SecretCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /secrets/sec_def456
                    "@type": Secret
                    ID: sec_def456
                    Version: { Index: 8 }
                    CreatedAt: "2025-02-01T09:00:00Z"
                    UpdatedAt: "2025-02-01T09:00:00Z"
                    Spec:
                      Name: db_password
                      Labels:
                        com.docker.stack.namespace: myapp
                total: 3
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SecretCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"
    post:
      operationId: createSecret
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Secrets]
      summary: Create a secret
      description: Creates a new secret in the swarm. Requires operations level 2.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, data]
              properties:
                name:
                  type: string
                  description: Secret name
                data:
                  type: string
                  format: byte
                  description: Secret data (base64-encoded)
            example:
              name: api_key
              data: c3VwZXItc2VjcmV0LWtleQ==
      responses:
        "201":
          description: Secret created
          headers:
            Location:
              schema:
                type: string
              description: Path to the created secret
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      secret:
                        type: object
                        description: Docker swarm.Secret object (data is always cleared)
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Secret name conflict

  /secrets/{id}:
    get:
      operationId: getSecret
      tags: [Secrets]
      summary: Secret detail
      description: |
        Returns the secret metadata (data always redacted) and
        a list of services using this secret.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Secret detail with cross-referenced services
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      secret:
                        type: object
                        description: Docker swarm.Secret object (data always nil)
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
              example:
                "@context": /api/context.jsonld
                "@id": /secrets/sec_def456
                "@type": Secret
                secret:
                  ID: sec_def456
                  Version: { Index: 8 }
                  CreatedAt: "2025-02-01T09:00:00Z"
                  UpdatedAt: "2025-02-01T09:00:00Z"
                  Spec:
                    Name: db_password
                    Labels:
                      com.docker.stack.namespace: myapp
                services:
                  - "@id": /services/r8m2x5nk3p7q
                    id: r8m2x5nk3p7q
                    name: webapp
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeSecret
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Secrets]
      summary: Remove a secret
      description: Removes a secret from the swarm. Fails if the secret is in use by a service. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Secret removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /secrets/{id}/labels:
    get:
      operationId: getSecretLabels
      tags: [Secrets]
      summary: Get secret labels
      description: Returns the current labels of the secret as a key/value map.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Secret labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: patchSecretLabels
      x-badges:
        - name: "operations-level:2"
          position: after
          color: orange
      tags: [Secrets]
      summary: Patch secret labels
      description: |
        Applies a JSON Patch (RFC 6902) or JSON Merge Patch (RFC 7396) to the secret labels.
        Requires `Content-Type: application/json-patch+json` or `application/merge-patch+json`.
        Setting `com.docker.stack.namespace` to a stack the secret is not in also requires
        `write` on that stack (`403 ACL002` otherwise).
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json-patch+json:
            schema:
              $ref: "#/components/schemas/JSONPatch"
            example:
              - op: add
                path: /rotated-at
                value: "2026-04-13"
              - op: replace
                path: /version
                value: "v2"
          application/merge-patch+json:
            schema:
              type: object
              additionalProperties:
                type: [string, "null"]
              description: Label keys to set (string value) or remove (null value)
            example:
              rotated-at: "2026-04-13"
              version: v2
      responses:
        "200":
          description: Updated secret labels
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      labels:
                        type: object
                        additionalProperties:
                          type: string
        "204":
          description: No content (Prefer return=minimal)
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  # ── Networks ─────────────────────────────────────────────────────────

  /networks:
    get:
      operationId: listNetworks
      tags: [Networks]
      summary: List networks
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name, driver, scope]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated network list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /networks/net_ghi789
                    "@type": Network
                    Id: net_ghi789
                    Name: myapp_default
                    Driver: overlay
                    Scope: swarm
                    IPAM:
                      Config:
                        - Subnet: 10.0.1.0/24
                    Labels:
                      com.docker.stack.namespace: myapp
                total: 6
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /networks/{id}:
    get:
      operationId: getNetwork
      tags: [Networks]
      summary: Network detail
      description: Returns the network and a list of services attached to it.
      parameters:
        - $ref: "#/components/parameters/resourceId"
      responses:
        "200":
          description: Network detail with cross-referenced services
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      network:
                        type: object
                        description: Docker network.Summary object
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
              example:
                "@context": /api/context.jsonld
                "@id": /networks/net_ghi789
                "@type": Network
                network:
                  Id: net_ghi789
                  Name: myapp_default
                  Driver: overlay
                  Scope: swarm
                  IPAM:
                    Config:
                      - Subnet: 10.0.1.0/24
                        Gateway: 10.0.1.1
                  Labels:
                    com.docker.stack.namespace: myapp
                services:
                  - "@id": /services/r8m2x5nk3p7q
                    id: r8m2x5nk3p7q
                    name: webapp
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeNetwork
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Networks]
      summary: Remove a network
      description: Removes a network from the swarm. Fails if services are still attached. Requires operations level 3.
      parameters:
        - $ref: "#/components/parameters/resourceId"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Network removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  # ── Volumes ──────────────────────────────────────────────────────────

  /volumes:
    get:
      operationId: listVolumes
      tags: [Volumes]
      summary: List volumes
      parameters:
        - $ref: "#/components/parameters/search"
        - $ref: "#/components/parameters/filter"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/Range"
        - name: sort
          in: query
          description: Field to sort the result list by
          schema:
            type: string
            enum: [name, driver, scope]
          example: name
        - $ref: "#/components/parameters/dir"
      responses:
        "200":
          description: Paginated volume list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Link:
              $ref: "#/components/headers/Link"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VolumeCollection"
              example:
                "@context": /api/context.jsonld
                "@type": Collection
                items:
                  - "@id": /volumes/myapp_data
                    "@type": Volume
                    Name: myapp_data
                    Driver: local
                    Scope: local
                    Mountpoint: /var/lib/docker/volumes/myapp_data/_data
                    Labels:
                      com.docker.stack.namespace: myapp
                total: 2
                limit: 50
                offset: 0
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "206":
          description: The page a `Range` request named, when it is less than the whole list
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Content-Range:
              $ref: "#/components/headers/Content-Range"
            Accept-Ranges:
              $ref: "#/components/headers/Accept-Ranges"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VolumeCollection"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"
        "416":
          $ref: "#/components/responses/RangeNotSatisfiable"

  /volumes/{name}:
    get:
      operationId: getVolume
      tags: [Volumes]
      summary: Volume detail
      description: |
        Returns the volume and a list of services using it.
        Note: volumes are keyed by name, not ID.
      parameters:
        - name: name
          in: path
          required: true
          description: Volume name
          schema:
            type: string
          example: myapp_data
      responses:
        "200":
          description: Volume detail with cross-referenced services
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Allow:
              $ref: "#/components/headers/Allow"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      volume:
                        type: object
                        description: Docker volume.Volume object
                      services:
                        type: array
                        items:
                          $ref: "#/components/schemas/ServiceRef"
              example:
                "@context": /api/context.jsonld
                "@id": /volumes/myapp_data
                "@type": Volume
                volume:
                  Name: myapp_data
                  Driver: local
                  Scope: local
                  Mountpoint: /var/lib/docker/volumes/myapp_data/_data
                  Labels:
                    com.docker.stack.namespace: myapp
                services:
                  - "@id": /services/r8m2x5nk3p7q
                    id: r8m2x5nk3p7q
                    name: webapp
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: removeVolume
      x-badges:
        - name: "operations-level:3"
          position: after
          color: red
      tags: [Volumes]
      summary: Remove a volume
      description: |
        Removes a volume. Fails with 409 if the volume is in use by a container.
        On a local volume, `?force=true` removes it even if its driver reports an error; a volume a
        container is using is refused either way, with `409 VOL001`. On a cluster volume,
        `?force=true` deletes it even while it is in use.
        Requires operations level 3.
      parameters:
        - name: name
          in: path
          required: true
          description: Volume name
          schema:
            type: string
          example: myapp_data
        - name: force
          in: query
          description: Remove a local volume even if its driver reports an error, or a cluster volume even while it is in use. Does not override use of a local volume by a container.
          schema:
            type: boolean
            default: false
          example: false
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204":
          description: Volume removed
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  # ── Search ───────────────────────────────────────────────────────────

  /search:
    get:
      operationId: search
      tags: [Search]
      summary: Global cross-resource search
      description: |
        Searches names, images, and labels across all resource types.
        Results are grouped by type. The `limit` parameter controls max results
        per type (default 3; 0 means up to 1000 per type).
      parameters:
        - name: q
          in: query
          required: true
          description: Search query (case-insensitive substring match, max 200 chars)
          schema:
            type: string
            maxLength: 200
          example: web
        - name: limit
          in: query
          description: Max results per resource type (0 = up to 1000)
          schema:
            type: integer
            default: 3
            minimum: 0
          example: 5
      responses:
        "200":
          description: Grouped search results
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
              example:
                "@context": /api/context.jsonld
                "@id": /search
                "@type": SearchResult
                query: web
                results:
                  services:
                    - id: r8m2x5nk3p7q
                      name: webapp
                      detail: "nginx:1.27"
                      state: running
                  stacks:
                    - id: myapp
                      name: myapp
                counts:
                  services: 1
                  stacks: 1
                total: 2
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/BadRequest"

  # ── Topology ─────────────────────────────────────────────────────────

  /topology:
    get:
      operationId: getTopology
      tags: [Topology]
      summary: Cluster topology graph (content-negotiated)
      description: |
        Returns a graph of the cluster topology in one of three formats, selected
        by the `Accept` header:
        - `application/vnd.jgf+json`: JSON Graph Format (also served for `application/json`)
        - `application/graphml+xml`: GraphML XML
        - `text/vnd.graphviz`: Graphviz DOT

        HTML requests return the SPA. Unrecognized types return 406.
      responses:
        "200":
          description: Topology graph in the negotiated format
          content:
            application/vnd.jgf+json:
              schema:
                type: object
                description: JSON Graph Format (JGF) document
            application/graphml+xml:
              schema:
                type: string
                description: GraphML XML document
            text/vnd.graphviz:
              schema:
                type: string
                description: Graphviz DOT source
        "406":
          $ref: "#/components/responses/NotAcceptable"

  /recommendations:
    get:
      operationId: getRecommendations
      x-scalar-stability: experimental
      tags: [Recommendations]
      summary: Cluster health recommendations
      description: |
        Returns all active recommendations across four domains: resource sizing,
        config hygiene, operational health, and cluster topology. Items are sorted
        by severity (critical first). Includes a summary with severity counts.
      responses:
        "200":
          description: Recommendation collection
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DetailEnvelope"
                  - type: object
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/Recommendation"
                      total:
                        type: integer
                      summary:
                        $ref: "#/components/schemas/RecommendationSummary"
                      computedAt:
                        type: string
                        format: date-time
              example:
                "@context": /api/context.jsonld
                "@id": /recommendations
                "@type": RecommendationCollection
                items:
                  - category: no-healthcheck
                    severity: warning
                    scope: service
                    targetId: r8m2x5nk3p7q
                    targetName: webapp
                    message: Service has no health check configured
                  - category: single-replica
                    severity: info
                    scope: service
                    targetId: j4n6w8t2k9m1
                    targetName: api
                    message: Service runs a single replica with no redundancy
                total: 2
                limit: 2
                offset: 0
                summary:
                  critical: 0
                  warning: 1
                  info: 1
                computedAt: "2025-03-20T14:35:00Z"
            application/atom+xml:
              schema:
                $ref: "#/components/schemas/AtomFeed"
            application/feed+json:
              schema:
                $ref: "#/components/schemas/JSONFeed"
            text/csv:
              schema:
                $ref: "#/components/schemas/CSVDocument"
        "304":
          $ref: "#/components/responses/NotModified"

# ════════════════════════════════════════════════════════════════════════
# Components
# ════════════════════════════════════════════════════════════════════════

components:

  # ── Security Schemes ────────────────────────────────────────────────

  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: __Host-cetacean_session
      description: Session cookie set after OIDC authentication
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Bearer token for machine-to-machine authentication: an access token this
        deployment's OAuth 2.1 authorization server issued for the API, or in
        `oidc` mode an ID token from the configured IdP. A token issued for the
        `/mcp` resource is refused here; the 401 names the API's own protected
        resource metadata document.

  # ── Parameters ───────────────────────────────────────────────────────

  parameters:
    resourceId:
      name: id
      in: path
      required: true
      description: Resource ID
      schema:
        type: string
      example: qz7k3v9xm2p4

    IfMatch:
      name: If-Match
      in: header
      required: false
      schema:
        type: string
      description: >
        Optional RFC 9110 precondition. Supply the ETag from a GET on this
        same path; the write is refused with 412 if the resource has
        changed since. Omitting the header skips the check.
      example: '"a1b2c3d4e5f6a1b2"'

    IfMatchUnrepresented:
      name: If-Match
      in: header
      required: false
      schema:
        type: string
      description: >
        No GET serves this path, so there is no ETag for an If-Match to match:
        any If-Match, `*` included, is refused with 412 unless the resource is
        missing, which still answers 404. Omit the header.
      example: '"a1b2c3d4e5f6a1b2"'

    Prefer:
      name: Prefer
      in: header
      required: false
      schema:
        type: string
      description: >
        Optional RFC 7240 convergence preferences, comma-separated.
        `wait=<seconds>` holds the response until the cluster has settled on
        the change, up to a server ceiling of 300 seconds; the response then
        carries `Preference-Applied: wait=<seconds>` reporting the wait
        actually applied. A wait that runs out answers 202 with the last
        progress line and no `Preference-Applied`. Past 32 concurrent waits, a
        wait answers 202 at once with a `Retry-After`. `respond-async` answers
        202 immediately. Omitting the header leaves the endpoint's behaviour
        unchanged. The unrelated `return=minimal` preference is honoured on
        every write endpoint, this one included, and is not documented per
        endpoint.
      example: wait=30

    search:
      name: search
      in: query
      description: Case-insensitive substring search on resource names
      schema:
        type: string
      example: web

    filter:
      name: filter
      in: query
      description: |
        expr-lang filter expression (max 512 characters).
        Available fields depend on the resource type.
      schema:
        type: string
        maxLength: 512
      example: role == "manager" && state == "ready"

    limit:
      name: limit
      in: query
      description: Page size
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50

    offset:
      name: offset
      in: query
      description: Starting offset for pagination
      schema:
        type: integer
        minimum: 0
        default: 0

    Range:
      name: Range
      in: header
      required: false
      description: >
        Pagination by item range, an alternative to `limit` and `offset`:
        `items <first>-<last>`, both zero-based and inclusive, with the same
        200-item cap. Ignored when either query parameter is present. A range
        that is less than the whole list answers 206 with `Content-Range`; one
        that starts past the end, or names several ranges, answers 416. JSON
        only: a CSV download answers 200 with every row.
      schema:
        type: string
      example: items 0-49

    dir:
      name: dir
      in: query
      description: Sort direction
      schema:
        type: string
        enum: [asc, desc]
        default: asc

    logLimit:
      name: limit
      in: query
      description: Max log lines to return (JSON mode only)
      schema:
        type: integer
        minimum: 1
        maximum: 10000
        default: 500

    logAfter:
      name: after
      in: query
      description: |
        Return lines after this timestamp (RFC 3339, at any precision and in
        any offset, or a Go duration meaning that long ago).
        For SSE, Last-Event-ID header is used as fallback.
        On an SSE stream this resumes from the cursor. The stream reaches back
        at most 500 lines per task for the ones missed while disconnected —
        so a multi-replica service replays up to 500 lines per replica — and
        older overflow is dropped. The cursor narrows that replay only: output
        the connection itself receives is delivered whatever its timestamp,
        since a task is stamped by the clock of the node running it.
      schema:
        type: string

    logBefore:
      name: before
      in: query
      description: |
        Return lines before this timestamp (RFC 3339 or Go duration).
        Not supported for SSE streams (returns 400).
      schema:
        type: string

    logStream:
      name: stream
      in: query
      description: Filter by output stream
      schema:
        type: string
        enum: [stdout, stderr]

  # ── Headers ──────────────────────────────────────────────────────────

  headers:
    ETag:
      description: >
        Strong validator for conditional requests: send it back as
        `If-None-Match` to revalidate a cached copy, or as `If-Match` to make
        a write conditional on the resource not having changed. A compressed
        representation carries its content-coding as a suffix inside the
        quotes; the hash itself is always of the uncompressed body, so a
        suffixed validator is still accepted verbatim as `If-Match`.
      schema:
        type: string
        example: '"a1b2c3d4e5f60718"'

    Content-Encoding:
      description: >
        Content-coding applied to the response body, when the request's
        `Accept-Encoding` offered one and the body exceeds 1 KiB. Absent
        otherwise. `Vary: Accept-Encoding` is sent either way.
      schema:
        type: string
        enum: [zstd, gzip]
        example: zstd

    Allow:
      description: >
        The methods the caller may use on this resource: GET and HEAD, plus
        each write routed here that both the operations level and the
        caller's write grant permit
      schema:
        type: string
        example: GET, HEAD, PUT, PATCH, DELETE

    Link:
      description: |
        RFC 8288 links: pagination (rel="next", rel="prev") on a list, and on a
        detail response one rel="edit" per sub-resource the caller may write
      schema:
        type: string
        example: '</nodes?limit=50&offset=50>; rel="next"'

    Accept-Ranges:
      description: Always `items`, the unit a list's `Range` header takes
      schema:
        type: string
        enum: [items]

    Content-Range:
      description: >
        The items a 206 holds and the total, as `items <first>-<last>/<total>`;
        on a 416 for a range past the end, `items */<total>`
      schema:
        type: string
        example: items 0-49/120

    Retry-After:
      description: Seconds to wait before retrying
      schema:
        type: integer
        example: 5

  # ── Responses ────────────────────────────────────────────────────────

  responses:
    NotModified:
      description: Not modified (ETag matched If-None-Match)

    ServiceAccepted:
      description: >
        Accepted: Docker took the change, but the cluster has not settled on
        it. Returned when `Prefer: respond-async` was sent, when a
        `Prefer: wait` ran out before the service converged, or when the
        server already holds 32 waits. Poll or stream the `Location` URL to
        follow the rollout.
      headers:
        Retry-After:
          description: >
            Seconds to wait before polling `Location`. Sent only on a wait
            refused because the server already holds 32.
          schema:
            type: integer
            example: 5
        Location:
          description: The service to read convergence from
          schema:
            type: string
            example: /services/r8m2x5nk3p7q
        Preference-Applied:
          description: >
            `respond-async` when that preference was applied. Absent on a
            wait that ran out — the preference was asked for and not met.
          schema:
            type: string
            example: respond-async
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ServiceAccepted"

    BadRequest:
      description: Bad request (invalid filter, missing parameter, etc.)
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          examples:
            invalidFilter:
              summary: Invalid filter expression (FLT002)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/FLT002
                title: Invalid Filter Expression
                status: 400
                detail: "unexpected token at position 14: expected identifier"
                instance: /services
                requestId: req_a1b2c3d4e5f6
            invalidJSON:
              summary: Invalid JSON in request body (API008)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/API008
                title: Invalid JSON
                status: 400
                detail: "unexpected end of JSON input"
                instance: /services/r8m2x5nk3p7q/env
                requestId: req_a1b2c3d4e5f6
            patchApplyFailed:
              summary: JSON Patch application failed (API011)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/API011
                title: Patch Application Failed
                status: 400
                detail: "test operation failed: /NODE_ENV expected 'staging' got 'production'"
                instance: /services/r8m2x5nk3p7q/env
                requestId: req_a1b2c3d4e5f6

    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: about:blank
            title: Not Found
            status: 404
            detail: "service r8m2x5nk3p7q not found"
            instance: /services/r8m2x5nk3p7q
            requestId: req_a1b2c3d4e5f6

    NotAcceptable:
      description: Requested content type not supported for this endpoint
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          examples:
            sseNotSupported:
              summary: SSE requested on non-streaming endpoint (API001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/API001
                title: SSE Not Supported
                status: 406
                detail: "this endpoint does not support text/event-stream"
                instance: /services/r8m2x5nk3p7q
                requestId: req_a1b2c3d4e5f6
            sseRequired:
              summary: SSE required on streaming-only endpoint (API002)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/API002
                title: SSE Required
                status: 406
                detail: "this endpoint only supports text/event-stream"
                instance: /events
                requestId: req_a1b2c3d4e5f6

    Conflict:
      description: Version conflict — the resource was modified concurrently; retry the operation
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: /api/errors/SVC001
            title: Service Version Conflict
            status: 409
            detail: Service was modified concurrently; retry the operation with the latest version.
            instance: /services/r8m2x5nk3p7q/image
            requestId: req_a1b2c3d4e5f6

    UnsupportedMediaType:
      description: Unsupported Content-Type for this endpoint
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: /api/errors/API006
            title: Invalid Request Body
            status: 415
            detail: "PATCH requires Content-Type: application/json-patch+json or application/merge-patch+json"
            instance: /services/r8m2x5nk3p7q/env
            requestId: req_a1b2c3d4e5f6

    PreconditionFailed:
      description: The If-Match precondition failed — the resource has changed since the supplied ETag was read
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: /api/errors/API013
            title: Precondition Failed
            status: 412
            detail: If-Match did not match the current state of the resource
            instance: /services/r8m2x5nk3p7q/env
            requestId: req_a1b2c3d4e5f6

    RangeNotSatisfiable:
      description: >
        The `Range` starts past the last item, with `Content-Range: items */<total>`,
        or names more than one range, with no `Content-Range`
      headers:
        Content-Range:
          $ref: "#/components/headers/Content-Range"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: about:blank
            title: Requested Range Not Satisfiable
            status: 416
            detail: range start is beyond the total number of items
            instance: /tasks
            requestId: req_a1b2c3d4e5f6

    BackendUnavailable:
      description: The Docker Engine could not be reached, so the request could not be evaluated
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          example:
            "@context": /api/context.jsonld
            type: /api/errors/ENG001
            title: Docker Engine Unavailable
            status: 503
            detail: "error during connect: Get \"http://docker/v1.51/plugins/vieux/sshfs:latest/json\": EOF"
            instance: /plugins/vieux/sshfs:latest
            requestId: req_a1b2c3d4e5f6
    Forbidden:
      description: Access denied by operations level, ACL, cross-origin protection, or a rejected credential no HTTP challenge can ask for
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          examples:
            operationsLevel:
              summary: Operations level too low (OPS001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/OPS001
                title: Operations Level Too Low
                status: 403
                detail: "service removal requires operations level 3 (currently 1)"
                instance: /services/r8m2x5nk3p7q
                requestId: req_a1b2c3d4e5f6
            aclReadDenied:
              summary: ACL read denied (ACL001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/ACL001
                title: Access Denied
                status: 403
                detail: "no grant matches service:webapp for user@example.com"
                instance: /services/r8m2x5nk3p7q
                requestId: req_a1b2c3d4e5f6
            aclWriteDenied:
              summary: ACL write denied (ACL002)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/ACL002
                title: Write Access Denied
                status: 403
                detail: "grants for service:webapp permit read but not write"
                instance: /services/r8m2x5nk3p7q/image
                requestId: req_a1b2c3d4e5f6
            crossOriginBlocked:
              summary: Cross-origin write blocked (CSR001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/CSR001
                title: Cross-Origin Request Blocked
                status: 403
                detail: "cross-origin request detected from Sec-Fetch-Site header"
                instance: /services/r8m2x5nk3p7q/image
                requestId: req_a1b2c3d4e5f6

    TooManyRequests:
      description: Too many concurrent connections
      headers:
        Retry-After:
          $ref: "#/components/headers/Retry-After"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
          examples:
            sseLimit:
              summary: SSE broadcaster limit reached (SSE001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/SSE001
                title: Too Many SSE Connections
                status: 429
                detail: "server is at the 256-client SSE limit; retry after the period indicated"
                instance: /events
                requestId: req_a1b2c3d4e5f6
            logStreamLimit:
              summary: Per-endpoint log stream limit (LOG001)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/LOG001
                title: Too Many Log Streams
                status: 429
                detail: "server is at the 128-client log-stream limit"
                instance: /services/r8m2x5nk3p7q/logs
                requestId: req_a1b2c3d4e5f6
            logReadLimit:
              summary: Log read limit (LOG009)
              value:
                "@context": /api/context.jsonld
                type: /api/errors/LOG009
                title: Too Many Log Reads
                status: 429
                detail: "too many log reads in flight"
                instance: /services/r8m2x5nk3p7q/logs
                requestId: req_a1b2c3d4e5f6

  # ── Schemas ──────────────────────────────────────────────────────────

  schemas:

    # ── Atom Feed ──────────────────────────────────────────────────

    AtomFeed:
      type: string
      description: |
        Atom 1.0 feed document (RFC 4287). Contains resource change history
        entries with cursor-based pagination links (RFC 5005). Request via
        `Accept: application/atom+xml` header or `.atom` URL suffix.

        Pagination parameters: `?before=<cursor>&limit=<n>` (default 50, max 200).

    JSONFeed:
      type: object
      description: |
        JSON Feed 1.1 document carrying the same entries as the Atom feed.
        Request via `Accept: application/feed+json` or the `.feed` URL suffix.
      required: [version, title, items]
      properties:
        version:
          type: string
          const: https://jsonfeed.org/version/1.1
        title:
          type: string
        items:
          type: array
          items:
            type: object
      additionalProperties: true

    # ── CSV ────────────────────────────────────────────────────────────

    ComposeDocument:
      type: string
      description: |
        The resource rendered as a Compose YAML document, served as
        `application/yaml; charset=utf-8`. Request via `Accept: application/yaml`
        or a `.yaml` URL suffix. It redeploys to the same running state on the
        same cluster; it is not the file that originally created the stack.
        Secrets and configs are referenced as `external`, never exported, so
        another cluster needs them created first.

    CSVDocument:
      type: string
      description: |
        RFC 4180 CSV with a header row, served as
        `text/csv; charset=utf-8; header=present` under an RFC 6266
        `Content-Disposition` naming a dated file. Request via
        `Accept: text/csv` or a `.csv` URL suffix.

        A resource list renders the compact row — name, state, and the one
        secondary fact that identifies the type, plus replica counts where
        they mean something — rather than the full Docker object. `/history`
        and `/recommendations` carry their own columns.

        Unlike the JSON representation, a request naming no page (`limit` or
        `offset`) renders every row rather than the first 50: a truncated
        export carries nothing in the file to say it was truncated. A `Range`
        header is not honoured here, since this response is always `200` with
        no `Content-Range`. `search`, `filter`, `sort` and ACL filtering apply
        exactly as they do to the JSON.

        A cell starting with `=`, `+`, `-`, `@`, a tab or a carriage return is
        prefixed with `'`, so a spreadsheet shows it as a label instead of
        running it as a formula. A number such as `-1` is left as it is.

    # ── Error ────────────────────────────────────────────────────────

    ProblemDetail:
      type: object
      description: |
        RFC 9457 Problem Details with JSON-LD @context.
        Domain-specific errors use `/api/errors/{code}` as the type URI
        (e.g. `/api/errors/SVC001`). Generic errors use `about:blank`.
      required: [type, title, status]
      properties:
        "@context":
          type: string
        type:
          type: string
          description: "Problem type URI — `/api/errors/{code}` for domain errors, `about:blank` for generic"
        title:
          type: string
          description: Short human-readable summary
        status:
          type: integer
          description: HTTP status code
        detail:
          type: string
          description: Human-readable explanation
        instance:
          type: string
          description: Request path that generated the error
        requestId:
          type: string
          description: Correlation ID for this request
      example:
        "@context": /api/context.jsonld
        type: /api/errors/SVC001
        title: Service Version Conflict
        status: 409
        detail: Service was modified concurrently; retry the operation with the latest version.
        instance: /services/r8m2x5nk3p7q/image
        requestId: req_a1b2c3d4e5f6

    ErrorDef:
      type: object
      description: Documentation for a well-known error code
      required: [code, title, status, description, suggestion]
      properties:
        code:
          type: string
          description: "Stable error code (three-letter prefix + three-digit number)"
        title:
          type: string
          description: Short human-readable title
        status:
          type: integer
          description: HTTP status code this error produces
        description:
          type: string
          description: Detailed explanation of the error
        suggestion:
          type: string
          description: Recommended action to resolve the error
      example:
        code: SVC001
        title: Service Version Conflict
        status: 409
        description: A service write operation was rejected because the service has been modified since the version was read.
        suggestion: Re-fetch the service to get the current version, then retry the write.

    # ── JSON-LD Envelopes ────────────────────────────────────────────

    DetailEnvelope:
      type: object
      description: JSON-LD wrapper for detail endpoints
      properties:
        "@context":
          type: string
          example: /api/context.jsonld
        "@id":
          type: string
          description: Canonical path of this resource
          example: /nodes/abc123
        "@type":
          type: string
          description: Resource type name
          example: Node

    CollectionEnvelope:
      type: object
      description: JSON-LD wrapper for list endpoints
      required: [items, total, limit, offset]
      properties:
        "@context":
          type: string
          example: /api/context.jsonld
        "@type":
          type: string
          example: Collection
        items:
          type: array
          items: {}
        total:
          type: integer
          description: Total number of items matching the query
        limit:
          type: integer
          description: Page size used
        offset:
          type: integer
          description: Starting offset used

    # ── Collections ──────────────────────────────────────────────────

    NodeCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker swarm.Node object with JSON-LD fields
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /nodes/abc123)
                  "@type":
                    type: string
                    description: JSON-LD type name (Node)

    ServiceCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/ServiceListItem"

    TaskCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/EnrichedTask"

    StackCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/Stack"

    ConfigCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker swarm.Config object with JSON-LD fields
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /configs/abc123)
                  "@type":
                    type: string
                    description: JSON-LD type name (Config)

    SecretCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker swarm.Secret object with JSON-LD fields (data always nil)
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /secrets/abc123)
                  "@type":
                    type: string
                    description: JSON-LD type name (Secret)

    NetworkCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker network.Summary object with JSON-LD fields
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /networks/abc123)
                  "@type":
                    type: string
                    description: JSON-LD type name (Network)

    VolumeCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker volume.Volume object with JSON-LD fields
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /volumes/myvolume)
                  "@type":
                    type: string
                    description: JSON-LD type name (Volume)

    HistoryCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/HistoryEntry"

    DiskUsageCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/DiskUsageSummary"

    PluginCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                type: object
                description: Docker Plugin object with JSON-LD fields
                properties:
                  "@id":
                    type: string
                    description: JSON-LD item identifier (e.g. /plugins/abc123)
                  "@type":
                    type: string
                    description: JSON-LD type name (Plugin)

    StackSummaryCollection:
      allOf:
        - $ref: "#/components/schemas/CollectionEnvelope"
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/StackSummary"

    # ── Resource Types ───────────────────────────────────────────────

    ServiceRef:
      type: object
      description: Lightweight service cross-reference
      properties:
        "@id":
          type: string
          description: Canonical path
          example: /services/abc123
        id:
          type: string
        name:
          type: string

    ServiceListItem:
      type: object
      description: Service with running task count (extends Docker swarm.Service)
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /services/abc123)
        "@type":
          type: string
          description: JSON-LD type name (Service)
        ID:
          type: string
        Spec:
          type: object
          description: Docker ServiceSpec
        RunningTasks:
          type: integer
          description: Number of currently running tasks
      example:
        ID: r8m2x5nk3p7q
        Version: { Index: 156 }
        Spec:
          Name: webapp
          Labels: { com.docker.stack.namespace: myapp }
          TaskTemplate:
            ContainerSpec: { Image: "nginx:1.27@sha256:abc123..." }
            Resources:
              Limits: { NanoCPUs: 1000000000, MemoryBytes: 536870912 }
          Mode:
            Replicated: { Replicas: 3 }
        RunningTasks: 3

    EnrichedTask:
      type: object
      description: Docker swarm.Task with added ServiceName and NodeHostname
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /tasks/abc123)
        "@type":
          type: string
          description: JSON-LD type name (Task)
        ID:
          type: string
        ServiceID:
          type: string
        NodeID:
          type: string
        Status:
          type: object
          description: Docker TaskStatus
        ServiceName:
          type: string
          description: Resolved from cache
        NodeHostname:
          type: string
          description: Resolved from cache
      example:
        ID: tsk_abc123def456
        ServiceID: r8m2x5nk3p7q
        ServiceName: webapp
        NodeID: qz7k3v9xm2p4
        NodeHostname: node-1
        Slot: 1
        DesiredState: running
        Status:
          State: running
          Timestamp: "2026-04-13T08:12:34Z"
          ContainerStatus:
            ContainerID: 8f4b9a2c1d3e
            PID: 4242

    Stack:
      type: object
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /stacks/myapp)
        "@type":
          type: string
          description: JSON-LD type name (Stack)
        name:
          type: string
        services:
          type: array
          items:
            type: string
          description: Service IDs
        configs:
          type: array
          items:
            type: string
          description: Config IDs
        secrets:
          type: array
          items:
            type: string
          description: Secret IDs
        networks:
          type: array
          items:
            type: string
          description: Network IDs
        volumes:
          type: array
          items:
            type: string
          description: Volume names
      example:
        name: myapp
        services: [r8m2x5nk3p7q, f3k9l2m7n8o4]
        configs: [cfg_nginx_conf]
        secrets: [sec_db_password]
        networks: [net_myapp_default]
        volumes: [myapp_postgres_data]

    StackDetail:
      type: object
      description: Full stack with resolved resources
      properties:
        name:
          type: string
        services:
          type: array
          items:
            type: object
            description: Docker swarm.Service objects
        configs:
          type: array
          items:
            type: object
            description: Docker swarm.Config objects
        secrets:
          type: array
          items:
            type: object
            description: Docker swarm.Secret objects (data redacted)
        networks:
          type: array
          items:
            type: object
            description: Docker network.Summary objects
        volumes:
          type: array
          items:
            type: object
            description: Docker volume.Volume objects

    StackSummary:
      type: object
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /stacks/myapp)
        "@type":
          type: string
          description: JSON-LD type name (StackSummary)
        name:
          type: string
        serviceCount:
          type: integer
        configCount:
          type: integer
        secretCount:
          type: integer
        networkCount:
          type: integer
        volumeCount:
          type: integer
        desiredTasks:
          type: integer
        tasksByState:
          type: object
          additionalProperties:
            type: integer
        updatingServices:
          type: integer
        memoryLimitBytes:
          type: integer
          format: int64
        cpuLimitCores:
          type: number
          format: double
        memoryUsageBytes:
          type: integer
          format: int64
          description: Populated from Prometheus (0 if unavailable)
        cpuUsagePercent:
          type: number
          format: double
          description: Populated from Prometheus (0 if unavailable)
      example:
        name: myapp
        serviceCount: 2
        configCount: 1
        secretCount: 1
        networkCount: 1
        volumeCount: 1
        desiredTasks: 5
        tasksByState: { running: 5 }
        updatingServices: 0
        memoryLimitBytes: 2147483648
        cpuLimitCores: 4.0
        memoryUsageBytes: 1073741824
        cpuUsagePercent: 35.2

    # ── Recommendations ─────────────────────────────────────────────

    Recommendation:
      type: object
      properties:
        category:
          type: string
          enum:
            - over-provisioned
            - approaching-limit
            - at-limit
            - no-limits
            - no-reservations
            - no-healthcheck
            - no-restart-policy
            - flaky-service
            - node-disk-full
            - node-memory-pressure
            - single-replica
            - manager-has-workloads
            - uneven-distribution
          x-enum-descriptions:
            - Service consistently uses far less than its reserved resources
            - Service usage is nearing its configured resource limit
            - Service is actively hitting its configured resource limit
            - Service has no CPU or memory limit set
            - Service has no CPU or memory reservation set
            - Service has no healthcheck defined
            - Service has no restart policy defined
            - Service is restarting frequently or failing tasks
            - Node's disk is nearly full
            - Node is under sustained memory pressure
            - Service has only one replica (no redundancy)
            - Manager node is running non-system workloads
            - Tasks for a service are unevenly distributed across nodes
        severity:
          type: string
          enum: [info, warning, critical]
          x-enum-descriptions:
            - Informational, no action required
            - Warrants attention but not urgent
            - Urgent, should be addressed immediately
        scope:
          type: string
          enum: [service, node, cluster]
          x-enum-descriptions:
            - Recommendation targets a specific service
            - Recommendation targets a specific node
            - Recommendation applies to the cluster as a whole
        targetId:
          type: string
        targetName:
          type: string
        resource:
          type: string
        message:
          type: string
        current:
          type: number
          description: >-
            The measured value. Absent when nothing was measured; present and
            zero when the measurement was zero.
        configured:
          type: number
          description: >-
            The configured value. Absent when the finding is that nothing is
            configured.
        suggested:
          type: number
        fixAction:
          type: string
      example:
        category: over-provisioned
        severity: info
        scope: service
        targetId: r8m2x5nk3p7q
        targetName: webapp
        resource: memory
        message: "Service uses 120 MiB on average but has 512 MiB reserved (23%)"
        current: 125829120
        configured: 536870912
        suggested: 268435456
        fixAction: Reduce memoryReservation to 256 MiB to free up capacity.

    RecommendationSummary:
      type: object
      properties:
        critical:
          type: integer
        warning:
          type: integer
        info:
          type: integer
      example:
        critical: 1
        warning: 4
        info: 7

    # ── Cluster ──────────────────────────────────────────────────────

    ClusterSnapshot:
      type: object
      properties:
        nodeCount:
          type: integer
        serviceCount:
          type: integer
        taskCount:
          type: integer
        stackCount:
          type: integer
        tasksByState:
          type: object
          additionalProperties:
            type: integer
        nodesReady:
          type: integer
        nodesDown:
          type: integer
        nodesDraining:
          type: integer
        totalCPU:
          type: integer
        totalMemory:
          type: integer
          format: int64
        servicesConverged:
          type: integer
        servicesDegraded:
          type: integer
        reservedCPU:
          type: integer
          format: int64
        reservedMemory:
          type: integer
          format: int64
        maxNodeCPU:
          type: integer
          description: CPU cores of the largest single node
        maxNodeMemory:
          type: integer
          format: int64
          description: Memory bytes of the largest single node
        lastSync:
          type: string
          format: date-time
        prometheusConfigured:
          type: boolean
        localNodeID:
          type: string
      example:
        nodeCount: 3
        serviceCount: 12
        taskCount: 36
        stackCount: 4
        tasksByState: { running: 34, shutdown: 2 }
        nodesReady: 3
        nodesDown: 0
        nodesDraining: 0
        totalCPU: 12
        totalMemory: 25769803776
        servicesConverged: 11
        servicesDegraded: 1
        reservedCPU: 6000000000
        reservedMemory: 12884901888
        maxNodeCPU: 4
        maxNodeMemory: 8589934592
        lastSync: "2026-04-13T12:00:00Z"
        prometheusConfigured: true
        localNodeID: qz7k3v9xm2p4

    ClusterCapacity:
      type: object
      properties:
        maxNodeCPU:
          type: integer
          description: CPU cores of the largest single node
        maxNodeMemory:
          type: integer
          format: int64
          description: Memory bytes of the largest single node
        totalCPU:
          type: integer
          description: Total CPU cores across all nodes
        totalMemory:
          type: integer
          format: int64
          description: Total memory bytes across all nodes
        nodeCount:
          type: integer
          description: Number of nodes in the cluster
      example:
        maxNodeCPU: 4
        maxNodeMemory: 8589934592
        totalCPU: 12
        totalMemory: 25769803776
        nodeCount: 3

    ClusterMetrics:
      type: object
      properties:
        cpu:
          $ref: "#/components/schemas/ResourceMetric"
        memory:
          $ref: "#/components/schemas/ResourceMetric"
        disk:
          $ref: "#/components/schemas/ResourceMetric"
      example:
        cpu: { used: 4.22, total: 12, percent: 35.2 }
        memory: { used: 10737418240, total: 25769803776, percent: 41.7 }
        disk: { used: 53687091200, total: 214748364800, percent: 25.0 }

    ResourceMetric:
      type: object
      properties:
        used:
          type: number
          format: double
        total:
          type: number
          format: double
        percent:
          type: number
          format: double
      example:
        used: 4.22
        total: 12
        percent: 35.2

    DiskUsageSummary:
      type: object
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /disk-usage/images)
        "@type":
          type: string
          description: JSON-LD type name (DiskUsageSummary)
        type:
          type: string
          enum: [images, containers, volumes, buildCache]
        count:
          type: integer
        active:
          type: integer
        totalSize:
          type: integer
          format: int64
        reclaimable:
          type: integer
          format: int64
      example:
        type: images
        count: 42
        active: 18
        totalSize: 12884901888
        reclaimable: 5368709120

    MonitoringStatus:
      type: object
      properties:
        prometheusConfigured:
          type: boolean
        prometheusReachable:
          type: boolean
        queryable:
          type: boolean
          description: |
            Whether this caller may query the Prometheus proxy (`GET /metrics`,
            `GET /metrics/labels`). Under an ACL policy, that needs `read` on `*`.
        error:
          type: string
          description: Diagnostic message when Prometheus is configured but unreachable.
        nodeExporter:
          anyOf:
            - $ref: "#/components/schemas/TargetStatus"
            - type: "null"
          description: Null when node-exporter targets cannot be detected.
        cadvisor:
          anyOf:
            - $ref: "#/components/schemas/TargetStatus"
            - type: "null"
          description: Null when cAdvisor targets cannot be detected.
      example:
        prometheusConfigured: true
        prometheusReachable: true
        queryable: true
        nodeExporter: { targets: 3, nodes: 3 }
        cadvisor: { targets: 3, nodes: 3 }

    TargetStatus:
      type: object
      properties:
        targets:
          type: integer
          description: Number of active targets detected
        nodes:
          type: integer
          description: Total nodes in the cluster (for comparison)
      example:
        targets: 3
        nodes: 3

    # ── History ──────────────────────────────────────────────────────

    HistoryEntry:
      type: object
      properties:
        "@id":
          type: string
          description: JSON-LD item identifier (e.g. /history/8421)
        "@type":
          type: string
          description: JSON-LD type name (HistoryEntry)
        id:
          type: integer
          format: uint64
        timestamp:
          type: string
          format: date-time
        type:
          type: string
          description: Resource type (node, service, task, etc.)
        action:
          type: string
          enum: [create, update, remove, ref_changed]
          description: >-
            Change action. `create` means the resource appeared, `update` that
            an existing one changed, `remove` that it is gone, and
            `ref_changed` that a resource this one cross-references changed.
        resourceId:
          type: string
        name:
          type: string
          description: >-
            The resource's name. A task carries the name Swarm gives it
            (`webapp.1`, or `webapp.<node ID>` in a global service), or its ID
            once the task or its service is gone.
        summary:
          type: string
      example:
        id: 8421
        timestamp: "2026-04-13T08:12:34Z"
        type: service
        action: update
        resourceId: r8m2x5nk3p7q
        name: webapp
        summary: Image updated to nginx:1.28

    # ── Logs ─────────────────────────────────────────────────────────

    LogResponse:
      type: object
      properties:
        lines:
          type: array
          items:
            $ref: "#/components/schemas/LogLine"
        oldest:
          type: string
          description: Timestamp of the oldest line returned
        newest:
          type: string
          description: Timestamp of the newest line returned
        hasMore:
          type: boolean
          description: Whether more lines exist beyond the limit
      example:
        lines:
          - timestamp: "2026-04-13T08:12:34.123456789Z"
            message: "172.18.0.1 - - [13/Apr/2026:08:12:34 +0000] \"GET / HTTP/1.1\" 200 612"
            stream: stdout
            attrs: { nodeId: qz7k3v9xm2p4, serviceId: r8m2x5nk3p7q, taskId: tsk_abc123def456 }
          - timestamp: "2026-04-13T08:12:35.987654321Z"
            message: "2026/04/13 08:12:35 [error] 42#42: upstream timed out"
            stream: stderr
            attrs: { nodeId: e9f0g1h2i3j4, serviceId: r8m2x5nk3p7q, taskId: tsk_ghi789jkl012 }
        oldest: "2026-04-13T08:12:34.123456789Z"
        newest: "2026-04-13T08:12:35.987654321Z"
        hasMore: true

    LogLine:
      type: object
      properties:
        timestamp:
          type: string
          description: RFC 3339 Nano timestamp
        message:
          type: string
        stream:
          type: string
          enum: [stdout, stderr]
        attrs:
          type: object
          additionalProperties:
            type: string
          description: Docker log attributes (e.g. task ID)
      example:
        timestamp: "2026-04-13T08:12:34.123456789Z"
        message: "172.18.0.1 - - [13/Apr/2026:08:12:34 +0000] \"GET / HTTP/1.1\" 200 612"
        stream: stdout
        attrs: { nodeId: qz7k3v9xm2p4, serviceId: r8m2x5nk3p7q, taskId: tsk_abc123def456 }

    # ── SSE ──────────────────────────────────────────────────────────

    SSEEvent:
      type: object
      description: |
        JSON payload of an SSE event. Single events have `event: <type>`,
        batched events have `event: batch` with an array of these objects.
      properties:
        "@id":
          type: string
          description: Canonical resource path
        "@type":
          type: string
          description: Resource type (Node, Service, Task, etc.)
        type:
          type: string
          description: Event type string (node, service, task, etc.)
        action:
          type: string
          enum: [create, update, remove, ref_changed, full_sync]
          description: >-
            Event action. `create` means the resource appeared, `update` that
            an existing one changed, `remove` that it is gone, and
            `ref_changed` that a resource this one cross-references changed.
            `full_sync` accompanies a `sync` event and asks the client to
            refetch, because the stream could not be replayed from its cursor.
        id:
          type: string
          description: Resource ID
        resource:
          type: object
          description: The full resource object (type varies by event type)
      example:
        "@id": /services/r8m2x5nk3p7q
        "@type": Service
        type: service
        action: update
        id: r8m2x5nk3p7q
        resource:
          ID: r8m2x5nk3p7q
          Version: { Index: 157 }
          Spec:
            Name: webapp
            TaskTemplate:
              ContainerSpec: { Image: "nginx:1.28@sha256:def456..." }

    # ── Search ───────────────────────────────────────────────────────

    SearchResponse:
      type: object
      properties:
        "@context":
          type: string
        "@id":
          type: string
        "@type":
          type: string
        query:
          type: string
        results:
          type: object
          description: Grouped results keyed by resource type
          additionalProperties:
            type: array
            items:
              $ref: "#/components/schemas/SearchResult"
        counts:
          type: object
          description: Total match count per type (may exceed returned results)
          additionalProperties:
            type: integer
        total:
          type: integer
          description: Total matches across all types
      example:
        "@context": /api/context.jsonld
        "@id": /search
        "@type": SearchResult
        query: web
        results:
          services:
            - id: r8m2x5nk3p7q
              name: webapp
              detail: "nginx:1.27@sha256:abc123..."
              state: running
          stacks:
            - id: webshop
              name: webshop
              detail: "3 services"
        counts: { services: 1, stacks: 1 }
        total: 2

    SearchResult:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        detail:
          type: string
          description: Context-dependent detail (image, driver, date, etc.)
        state:
          type: string
          description: Present for services and tasks
      example:
        id: r8m2x5nk3p7q
        name: webapp
        detail: "nginx:1.27@sha256:abc123..."
        state: running

    Identity:
      type: object
      required:
        - subject
        - displayName
        - provider
      properties:
        subject:
          type: string
          description: Unique identifier for the user/machine
        displayName:
          type: string
          description: Human-friendly display name
        email:
          type: string
          description: Email address (if available)
        groups:
          type: array
          items:
            type: string
          description: Group memberships (if available)
        provider:
          type: string
          enum: [none, oidc, tailscale, cert, headers]
          x-enum-descriptions:
            - Anonymous access (no authentication configured)
            - OIDC auth code flow or Bearer token
            - Tailscale identity via local daemon or tsnet
            - mTLS client certificate (with optional SPIFFE)
            - Trusted proxy headers
          description: Authentication provider that produced this identity
        raw:
          type: object
          additionalProperties: true
          description: All raw claims from the authentication provider
      example:
        subject: user@example.com
        displayName: Jane Doe
        email: user@example.com
        groups: [engineering, ops]
        provider: oidc

    # ── Write operation schemas ───────────────────────────────────────

    JSONPatch:
      type: array
      description: JSON Patch document (RFC 6902)
      items:
        type: object
        required: [op, path]
        properties:
          op:
            type: string
            enum: [add, remove, replace, test]
            description: Patch operation
          path:
            type: string
            description: Key name (with or without leading /)
          value:
            type: string
            description: New value (required for add/replace/test)
      example:
        - op: add
          path: /LOG_LEVEL
          value: debug
        - op: replace
          path: /NODE_ENV
          value: staging
        - op: remove
          path: /DEPRECATED_FLAG

    ServiceResources:
      type: object
      description: |
        A service's resource requirements. An unset requirement is an empty object.
      required: [resources]
      properties:
        resources:
          $ref: "#/components/schemas/ResourceRequirements"
      example:
        resources:
          Limits:
            NanoCPUs: 2000000000
            MemoryBytes: 536870912
          Reservations:
            NanoCPUs: 500000000
            MemoryBytes: 268435456

    ResourceRequirements:
      type: object
      description: |
        Docker's swarm.ResourceRequirements. `PATCH /services/{id}/resources` takes it
        as a JSON Merge Patch, so `null` removes a field.
      properties:
        Limits:
          type: [object, "null"]
          properties:
            NanoCPUs:
              type: integer
              format: int64
              description: CPU limit in billionths of a CPU
            MemoryBytes:
              type: integer
              format: int64
              description: Memory limit in bytes
            Pids:
              type: integer
              format: int64
              description: Maximum number of processes
        Reservations:
          type: [object, "null"]
          properties:
            NanoCPUs:
              type: integer
              format: int64
              description: CPU reserved in billionths of a CPU
            MemoryBytes:
              type: integer
              format: int64
              description: Memory reserved in bytes
            GenericResources:
              type: array
              description: Docker swarm.GenericResource entries, such as GPUs
              items:
                type: object

    ServicePlacement:
      type: object
      description: Placement constraints and preferences for a service
      properties:
        placement:
          type: object
          description: Docker swarm placement configuration
          properties:
            Constraints:
              type: array
              items:
                type: string
              description: Placement constraint expressions (e.g. "node.role == manager")
            Preferences:
              type: array
              description: Placement preference strategies
              items:
                type: object
            MaxReplicas:
              type: integer
              format: int64
              description: Maximum replicas per node (0 = unlimited)
            Platforms:
              type: array
              description: Target platforms
              items:
                type: object
      example:
        placement:
          Constraints: ["node.role == worker", "node.labels.zone == us-east-1a"]
          Preferences:
            - Spread: { SpreadDescriptor: "node.labels.zone" }
          MaxReplicas: 2
          Platforms:
            - { Architecture: amd64, OS: linux }

    ServicePorts:
      type: object
      description: Published port configurations for a service
      properties:
        ports:
          type: array
          items:
            type: object
            description: Docker swarm PortConfig
            properties:
              Protocol:
                type: string
                enum: [tcp, udp, sctp]
              TargetPort:
                type: integer
                format: int64
              PublishedPort:
                type: integer
                format: int64
              PublishMode:
                type: string
                enum: [ingress, host]
      example:
        ports:
          - Protocol: tcp
            TargetPort: 80
            PublishedPort: 8080
            PublishMode: ingress
          - Protocol: tcp
            TargetPort: 443
            PublishedPort: 8443
            PublishMode: ingress

    ServiceUpdatePolicy:
      type: object
      description: A service's rolling-update configuration. An unset policy is an empty object.
      required: [updatePolicy]
      properties:
        updatePolicy:
          $ref: "#/components/schemas/UpdateConfig"
      example:
        updatePolicy:
          Parallelism: 1
          Delay: 10000000000
          FailureAction: rollback
          Monitor: 5000000000
          MaxFailureRatio: 0
          Order: start-first

    ServiceRollbackPolicy:
      type: object
      description: A service's rollback configuration. An unset policy is an empty object.
      required: [rollbackPolicy]
      properties:
        rollbackPolicy:
          $ref: "#/components/schemas/UpdateConfig"
      example:
        rollbackPolicy:
          Parallelism: 1
          FailureAction: pause
          Monitor: 5000000000
          Order: stop-first

    UpdateConfig:
      type: object
      description: |
        Docker's swarm.UpdateConfig, which configures both updates and rollbacks. Its
        PATCH endpoints take it as a JSON Merge Patch, so `null` removes a field.
      properties:
        Parallelism:
          type: integer
          format: int64
          description: Tasks updated at once; 0 updates them all at once
        Delay:
          type: integer
          format: int64
          description: Delay between task updates, in nanoseconds
        FailureAction:
          type: string
          description: What happens when a task update fails; `rollback` applies to updates only
          enum: [continue, pause, rollback]
        Monitor:
          type: integer
          format: int64
          description: How long each updated task is watched for failure, in nanoseconds
        MaxFailureRatio:
          type: number
          format: float
          description: Fraction of tasks that may fail before the failure action applies
        Order:
          type: string
          enum: [stop-first, start-first]

    NullableStringArray:
      type: [array, "null"]
      items:
        type: string

    ContainerConfig:
      type: object
      description: |
        Subset of Docker container spec fields exposed for per-service
        configuration updates. Field names are camelCase. Arrays and nested
        objects serialize as `null` when unset.
      properties:
        command:
          $ref: "#/components/schemas/NullableStringArray"
        args:
          $ref: "#/components/schemas/NullableStringArray"
        dir:
          type: string
          description: Working directory
        user:
          type: string
        hostname:
          type: string
        init:
          type: [boolean, "null"]
        tty:
          type: boolean
        readOnly:
          type: boolean
        stopSignal:
          type: string
        stopGracePeriod:
          type: [integer, "null"]
          format: int64
          description: Grace period in nanoseconds
        capabilityAdd:
          $ref: "#/components/schemas/NullableStringArray"
        capabilityDrop:
          $ref: "#/components/schemas/NullableStringArray"
        groups:
          $ref: "#/components/schemas/NullableStringArray"
        hosts:
          $ref: "#/components/schemas/NullableStringArray"
        dnsConfig:
          type: [object, "null"]
          properties:
            nameservers:
              $ref: "#/components/schemas/NullableStringArray"
            search:
              $ref: "#/components/schemas/NullableStringArray"
            options:
              $ref: "#/components/schemas/NullableStringArray"

    ServiceLogDriver:
      type: object
      description: Log driver configuration for a service
      properties:
        logDriver:
          type: [object, "null"]
          description: Docker driver configuration
          properties:
            Name:
              type: string
              description: Log driver name (e.g. "json-file", "syslog", "none")
            Options:
              type: object
              additionalProperties:
                type: string
              description: Log driver options
      example:
        logDriver:
          Name: json-file
          Options:
            max-size: 10m
            max-file: "3"

    # ── Integrations ──────────────────────────────────────────────────

    ServiceDetail:
      description: Service detail response with JSON-LD envelope and detected integrations
      allOf:
        - $ref: "#/components/schemas/DetailEnvelope"
        - type: object
          properties:
            service:
              type: object
              description: Docker swarm.Service object
            integrations:
              type: array
              description: Detected third-party integrations (e.g. Traefik)
              items:
                $ref: "#/components/schemas/Integration"

    ServiceAccepted:
      description: >
        Service detail for a change the cluster has not settled on yet, with
        the last convergence line the wait observed
      allOf:
        - $ref: "#/components/schemas/DetailEnvelope"
        - type: object
          properties:
            service:
              type: object
              description: Docker swarm.Service object
            progress:
              type: string
              description: >
                The last convergence line observed, e.g.
                "waiting: 2/5 replicas running". Absent when the response was
                sent without waiting.
              example: "waiting: 2/5 replicas running"

    Integration:
      description: A detected third-party integration on a service
      discriminator:
        propertyName: name
        mapping:
          traefik: "#/components/schemas/TraefikIntegration"
          shepherd: "#/components/schemas/ShepherdIntegration"
          swarm-cronjob: "#/components/schemas/CronjobIntegration"
          diun: "#/components/schemas/DiunIntegration"
      oneOf:
        - $ref: "#/components/schemas/TraefikIntegration"
        - $ref: "#/components/schemas/ShepherdIntegration"
        - $ref: "#/components/schemas/CronjobIntegration"
        - $ref: "#/components/schemas/DiunIntegration"

    TraefikIntegration:
      type: object
      description: Traefik reverse proxy integration detected from service labels
      required: [name]
      properties:
        name:
          type: string
          enum: [traefik]
          description: Discriminator field identifying the integration type
        enabled:
          type: boolean
          description: Whether the Traefik integration is enabled
        routers:
          type: array
          items:
            $ref: "#/components/schemas/TraefikRouter"
        services:
          type: array
          items:
            $ref: "#/components/schemas/TraefikService"
        middlewares:
          type: array
          items:
            $ref: "#/components/schemas/TraefikMiddleware"
      example:
        name: traefik
        enabled: true
        routers:
          - name: webapp
            rule: "Host(`app.example.com`)"
            entrypoints: [websecure]
            tls: { certResolver: letsencrypt }
            middlewares: [rate-limit]
            service: webapp
            priority: 100
        services:
          - name: webapp
            port: 80
            scheme: http
        middlewares:
          - name: rate-limit
            type: rateLimit
            config: { average: 100, burst: 50 }

    TraefikRouter:
      type: object
      description: A Traefik router detected from service labels
      properties:
        name:
          type: string
          description: Router name
        rule:
          type: string
          description: Routing rule expression (e.g. "Host(`example.com`)")
        entrypoints:
          type: array
          items:
            type: string
          description: Entrypoint names this router listens on
        tls:
          $ref: "#/components/schemas/TraefikTLS"
        middlewares:
          type: array
          items:
            type: string
          description: Middleware names attached to this router
        service:
          type: string
          description: Backend service name this router forwards to
        priority:
          type: integer
          description: Router priority (higher wins on rule conflicts)

    TraefikTLS:
      type: object
      description: TLS configuration for a Traefik router
      properties:
        certResolver:
          type: string
          description: Certificate resolver name (e.g. "letsencrypt")
        domains:
          type: array
          items:
            $ref: "#/components/schemas/TraefikTLSDomain"
          description: Explicit domain entries for certificate generation
        options:
          type: string
          description: TLS options name

    TraefikTLSDomain:
      type: object
      description: A domain entry in Traefik TLS configuration
      properties:
        main:
          type: string
          description: Primary domain name
        sans:
          type: array
          items:
            type: string
          description: Subject alternative names

    TraefikService:
      type: object
      description: A Traefik service detected from service labels
      properties:
        name:
          type: string
          description: Service name
        port:
          type: integer
          description: Backend port number
        scheme:
          type: string
          description: Backend scheme (e.g. "http", "https", "h2c")

    TraefikMiddleware:
      type: object
      description: A Traefik middleware detected from service labels
      properties:
        name:
          type: string
          description: Middleware name
        type:
          type: string
          description: Middleware type (e.g. "redirectScheme", "basicAuth")
        config:
          type: object
          additionalProperties: true
          description: Middleware configuration key-value pairs

    ShepherdIntegration:
      type: object
      description: Shepherd auto-update integration detected from service labels
      required: [name]
      properties:
        name:
          type: string
          enum: [shepherd]
        enabled:
          type: boolean
        authConfig:
          type: string
          description: Registry auth configuration ID
      example:
        name: shepherd
        enabled: true

    CronjobIntegration:
      type: object
      description: Swarm Cronjob integration detected from service labels
      required: [name]
      properties:
        name:
          type: string
          enum: [swarm-cronjob]
        enabled:
          type: boolean
        schedule:
          type: string
          description: Cron expression
        skipRunning:
          type: boolean
        replicas:
          type: integer
        registryAuth:
          type: boolean
        queryRegistry:
          type: boolean
      example:
        name: swarm-cronjob
        enabled: true
        schedule: "0 3 * * *"
        skipRunning: true
        replicas: 1
        registryAuth: false
        queryRegistry: false

    DiunIntegration:
      type: object
      description: Diun image update notifier integration detected from service labels
      required: [name]
      properties:
        name:
          type: string
          enum: [diun]
        enabled:
          type: boolean
        regopt:
          type: string
          description: Registry options name
        watchRepo:
          type: boolean
        notifyOn:
          type: string
          description: "Semicolon-separated triggers (new, update)"
        sortTags:
          type: string
          enum: [default, reverse, semver, lexicographical]
        maxTags:
          type: integer
        includeTags:
          type: string
          description: Regex for tag inclusion
        excludeTags:
          type: string
          description: Regex for tag exclusion
        hubLink:
          type: string
        platform:
          type: string
        metadata:
          type: object
          additionalProperties:
            type: string
      example:
        name: diun
        enabled: true
        watchRepo: true
        notifyOn: new;update
        sortTags: semver
        maxTags: 5
        includeTags: '^\d+\.\d+\.\d+$'
        platform: linux/amd64
