Vetrix Docs
# OpenAPI 3.1 specification for the Vetrix CICDv2 (Vetrix Workflows) API.
# Published as part of the gitvetrix.com API reference.
openapi: 3.1.0
info:
  title: Vetrix CICDv2 API
  description: |
    OpenAPI 3.1 specification for the Vetrix CICDv2 (Vetrix Workflows)
    surface. Covers four endpoint families:

      1. **Internal control-plane** -- controller <-> host-agent RPCs
         under `/internal/host-pool/*` and `/api/v1/internal/host-pool/*`,
         transport-authenticated by mTLS (CICDv2 private CA, see
         `internal/cicdv2/mtls`).
      2. **Public admin API** -- operator-only surface under
         `/api/v1/admin/cicd-defaults`, `/api/v1/admin/hosts`,
         `/api/v1/admin/runners`, `/api/v1/admin/audit:cicd`,
         `/api/v1/admin/usage:cicd`. Bearer JWT auth with admin scopes
         (`acl.AdminRunners`, `acl.AdminAuditRead`, `acl.AdminSystem`).
      3. **Public tenant API** -- pipeline / job CRUD scoped to a
         repository, plus the per-org CICDv2 *policy* overlay. Bearer
         JWT auth with tenant scopes (`acl.PermCIRead`,
         `acl.PermCIWrite`).
      4. **Job-side API** -- endpoints called by the in-DinD runner with
         a `VETRIX_JOB_TOKEN` (typ=job-token JWT minted at dispatch by
         `auth.IssueJobTokenForJob`).

    ## Sunset: the `cicd_v2` dispatch controller and its feature flag

    The `cicd_v2` dispatch controller was sunset before it ever shipped
    a dispatch loop. The feature flag that selected between the v1
    worker path and the v2 controller path, and every backend surface
    that read it, were removed. Three operations that earlier revisions
    of this document specified as live contract have been **deleted
    from this specification**, because the routes are no longer
    registered and the server answers them with 404:

      - `GET /api/v1/orgs/{owner}/cicd` and
        `PATCH /api/v1/orgs/{owner}/cicd` -- the per-tenant `cicd_v2`
        flag overlay (`operationId` `getTenantCICDFlag` /
        `patchTenantCICDFlag`).
      - `POST /api/v1/runner-controller/jobs:next` -- the v1 worker
        long-poll deprecation stub, whose only behaviour was gated on
        the flag (`operationId` `runnerControllerJobsNext`).

    They are recorded in this note rather than retained as `deprecated`
    operations on purpose. This file is meant to drive client
    generation, request validation and contract testing; an operation
    left in `paths:` -- carrying `deprecated: true` or not -- still
    produces callable client methods and "Try it out" affordances for
    routes that cannot succeed. `deprecated` means "available but
    discouraged", which is not what these are.

    `GET` / `PATCH /api/v1/admin/cicd-defaults` survives the sunset and
    is still live, but it is a **different** surface: it edits the
    instance-wide CI/CD *policy* defaults persisted as `cicd.defaults.*`
    rows, and its PATCH body is per-field (`max_job_timeout` /
    `cpu_millis` / `memory_bytes` / `pids_max`). It has never accepted
    the `{"value": ...}` flag payload earlier revisions of this document
    attributed to it; that body is rejected with a 400, because the
    handler decodes with unknown fields disallowed. The per-org and
    per-repo `.../cicd-policy` endpoints belong to the same policy
    family.

    No endpoint in this document selects a dispatcher. There is exactly
    one dispatch path -- the v1 queue-only path -- and nothing can be
    switched onto or off it.

    None of the above touches the CICDv2 *pipeline schema* (the
    `version: 2` pipeline configuration format) or the CICDv2 host-pool
    control plane under `/internal/host-pool/*` and
    `/api/v1/admin/hosts`. Both are live and specified here as such.
  version: v00.12.22
  contact:
    name: Vetrix
    url: https://www.gitvetrix.com
  license:
    name: Proprietary
servers:
  - url: https://api.gitvetrix.com/api/v1
    description: Production API (read-only for external testing per repo CLAUDE.md)
  - url: https://api.gitvetrix.test/api/v1
    description: Local development stack
tags:
  - name: internal-control-plane
    description: Controller <-> host-agent RPCs. mTLS only.
  - name: public-admin
    description: Operator-only admin surface. Bearer JWT + admin scope.
  - name: public-tenant
    description: Tenant-scoped CICDv2 surface (pipelines, jobs, artifacts, policy overlay).
  - name: job-side
    description: Endpoints called by the in-DinD job runner with VETRIX_JOB_TOKEN.

security:
  - bearerAuth: []

paths:

  # =============================================================
  # Internal control-plane (mTLS only)
  # =============================================================

  /internal/host-pool/register:
    post:
      tags: [internal-control-plane]
      summary: Register a host-agent with the controller
      description: |
        Host-agent -> controller registration. Authenticates via mTLS
        (CICDv2 private CA pinned at the listener) plus an in-band
        single-use `host_attach_token` operator invite. On success the
        controller persists a `runner_hosts` row and returns the
        canonical `host_id` plus initial `state`.
      operationId: hostPoolRegister
      security:
        - mtlsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostPoolRegisterRequest'
      responses:
        '200':
          description: Registration accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostPoolRegisterResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /internal/host-pool/heartbeat:
    post:
      tags: [internal-control-plane]
      summary: Host-agent liveness + capacity heartbeat
      description: |
        Periodic host-agent heartbeat (default 30s). Reports current
        CPU + memory utilisation; the controller stamps
        `last_heartbeat_at` and updates `in_use_cpu` / `in_use_mem_bytes`
        for scheduler placement decisions. mTLS leaf CN must match the
        registered FQDN for the body's `host_id`.
      operationId: hostPoolHeartbeat
      security:
        - mtlsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostPoolHeartbeatRequest'
      responses:
        '204':
          description: Heartbeat accepted (no response body)
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /internal/host-pool/jobs/{job_id}/dispatch:
    post:
      tags: [internal-control-plane]
      summary: Dispatch a job spec to a host-agent
      description: |
        Controller-side endpoint a host-agent calls after being targeted
        for a job. Validates the JobSpec shape, authenticates the host
        identity from the mTLS peer cert subject (CN), and emits a
        `job.dispatched` audit row scoped to the job id.
      operationId: hostPoolDispatch
      security:
        - mtlsAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostPoolJobSpec'
      responses:
        '200':
          description: Dispatch accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostPoolDispatchAck'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }

  /internal/host-pool/jobs/{job_id}/complete:
    post:
      tags: [internal-control-plane]
      summary: Mark a job as complete
      description: |
        Host-agent reports a terminal job state. Updates
        `pipeline_jobs.state` (+ `exit_code` when supplied) and emits a
        `job.completed` audit row. Accepts wire spellings
        `succeeded` / `success` / `failed` / `cancelled`.
      operationId: hostPoolCompleteJob
      security:
        - mtlsAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostPoolCompleteJobRequest'
      responses:
        '204':
          description: Job marked complete (no response body)
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/internal/host-pool/jobs/{job_id}/logs:
    post:
      tags: [internal-control-plane]
      summary: Stream host-agent job logs to the controller
      description: |
        Chunked log ingest. The body is a newline-delimited stream of
        log frames (one line per frame). Each line is persisted via the
        ChunkStore and tee'd to the live in-memory LogBroker so
        existing SSE consumers see new bytes immediately.

        Supports `Last-Event-ID` for resume: the header's decimal byte
        offset tells the server which bytes the agent has already
        delivered. Frames whose cumulative byte position is at or
        below the offset are dropped server-side.

        Caps: line size <= 4 MiB; total request body <= 256 MiB.
      operationId: hostPoolLogsIngest
      security:
        - mtlsAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath'
        - in: header
          name: Last-Event-ID
          required: false
          schema:
            type: string
            description: Decimal byte offset to resume from.
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              type: string
              description: Newline-delimited UTF-8 log frames.
      responses:
        '200':
          description: Ingest accepted; counts of accepted / dropped bytes returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostPoolLogsResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '413':
          description: Request body exceeded `hostPoolLogsMaxBodyBytes` (256 MiB).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500': { $ref: '#/components/responses/InternalError' }

  # POST /api/v1/runner-controller/jobs:next (operationId
  # runnerControllerJobsNext) was specified here. It was the v1 worker
  # long-poll deprecation stub: 204 while the cluster-wide `cicd_v2`
  # flag was off, 410 once it was flipped on. The flag is gone, so the
  # 410 could never fire; the route was removed and is not registered on
  # any current deployment. See the sunset note in `info.description`.
  # Deliberately not retained as a `deprecated` operation -- see that
  # note for why.

  # =============================================================
  # Public admin API (bearer JWT + admin scope)
  # =============================================================

  /api/v1/admin/cicd-defaults:
    get:
      tags: [public-admin]
      summary: Read cluster-wide CICDv2 policy defaults
      description: |
        Returns the instance-wide CICDv2 *policy* defaults the policy
        resolver consults when a tenant, org, repo or pipeline does not
        override them: `max_job_timeout`, `cpu_millis`, `memory_bytes`
        and `pids_max`. They are persisted as `cicd.defaults.*` rows in
        `app_settings`; a key with no row yet reads back its registered
        built-in default (`6h` / `2000` / `2147483648` / `1024`) rather
        than a zero value.

        One handler serves this path -- `AdminCICDDefaultsHandler` in
        `internal/api/admin_cicd_defaults.go`. The response is always
        the typed policy envelope below.

        This is **not** a feature-flag surface. Earlier revisions of
        this document described a second handler on this path returning
        a `{scope, value, source}` flag envelope; that handler was
        removed with the `cicd_v2` sunset (see the note in
        `info.description`) and no deployment serves that shape here.

        Authz: `acl.AdminSystem`, enforced inline by the route's
        `requireAdminScope` check -- 401 without claims, 403 without the
        scope. The route is mounted only where the app-settings store is
        wired; where it is not, the path is absent from the router and
        chi answers 404.
      operationId: getCICDDefaults
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Typed cluster-wide policy-defaults envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CICDDefaultsResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [public-admin]
      summary: Update cluster-wide CICDv2 policy defaults
      description: |
        PATCH the instance-wide CICDv2 policy defaults. The body is
        **per-field**: any subset of `max_job_timeout`, `cpu_millis`,
        `memory_bytes` and `pids_max`. An omitted field is a no-op; an
        empty body (no recognised field supplied) is a 400.

        There is exactly one accepted body shape. The decoder rejects
        unknown fields, so a body carrying any other key -- including
        the `{"value": ...}` flag payload earlier revisions of this
        document listed on this path -- is a 400, not a flag write.
        That flag surface no longer exists anywhere; see the sunset
        note in `info.description`.

        Validation runs against the bounded validators registered on
        the `cicd.defaults.*` keys, whole-batch, before any write: an
        out-of-bounds value is a 422 and nothing is persisted; an
        unparseable value is a 400.

        On success the handler emits one `admin.cicd.defaults.update`
        audit row carrying `actor_user_id` and a `changed` sub-map of
        `{old, new}` per key whose value actually moved (keys PATCHed
        to their existing value are omitted from `changed`, but the row
        is still emitted). Audit emission is best-effort and never
        fails the request. The response body is the full re-read typed
        envelope, not just the mutated fields.

        Authz: `acl.AdminSystem`, enforced inline by the route's
        `requireAdminScope` check -- 401 without claims, 403 without the
        scope.
      operationId: patchCICDDefaults
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CICDDefaultsPatchRequest'
      responses:
        '200':
          description: Updated typed policy-defaults envelope (full re-read).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CICDDefaultsResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/admin/hosts:
    get:
      tags: [public-admin]
      summary: List CICDv2 runner hosts
      description: |
        Operator-only paginated view of `runner_hosts`. Each row carries
        capacity / in-use / state / heartbeat / derived `healthy` flag
        for the host fleet. Auth: `acl.AdminRunners`.
      operationId: listRunnerHosts
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PerPageQuery'
      responses:
        '200':
          description: Paginated list of runner hosts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunnerHostsListResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/admin/hosts/{id}/drain:
    post:
      tags: [public-admin]
      summary: Drain a runner host
      description: |
        Marks the runner host `draining` so the scheduler stops
        dispatching new jobs to it while in-flight jobs complete.
        Idempotent: a `409 host_already_drained` is returned when the
        host is already in a terminal drain state.

        Returns `202 Accepted` with a `Location` header pointing at the
        GET progress endpoint. Auth: `acl.AdminRunners`.
      operationId: drainRunnerHost
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/HostIDPath'
      responses:
        '202':
          description: "Drain accepted; poll the progress URL until `drained: true`."
          headers:
            Location:
              schema:
                type: string
              description: Relative URL of the drain-progress GET.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostDrainStatus'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Host is already drained or in a non-drainable state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    get:
      tags: [public-admin]
      summary: Poll runner-host drain progress
      description: |
        Read-only progress endpoint paired with POST .../drain. Reports
        current `state`, `in_flight_jobs` remaining, and the boolean
        `drained` flag (true once the host has no in-flight work).
      operationId: getRunnerHostDrainProgress
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/HostIDPath'
      responses:
        '200':
          description: Drain status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HostDrainStatus'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/admin/hosts/{id}/decommission:
    post:
      tags: [public-admin]
      summary: Decommission a drained runner host
      description: |
        Permanently removes a runner host from the CICDv2 fleet.
        Precondition: the host must already be drained
        (`state == drained`). On success the `runner_hosts` row is
        deleted atomically alongside teardown of the host's tenant
        networks.
      operationId: decommissionRunnerHost
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/HostIDPath'
      responses:
        '204':
          description: Decommissioned.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Host not in `drained` state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/admin/runners:
    get:
      tags: [public-admin]
      summary: List v1 CI runners
      description: |
        Lists registered v1 CI runners. Carries v1 / CICDv2 coexistence
        fields (`isolation_kind`, `ssh_capable`, `arch`, `os`,
        `cpu_millis`, `mem_mib`) for capacity-aware scheduling.
        Auth: `acl.AdminRunners`.
      operationId: listAdminRunners
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Runner list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AdminRunner'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [public-admin]
      summary: Register a new v1 CI runner
      description: |
        Creates a new runner record. Returns the raw token EXACTLY
        ONCE in the response body (`token` / `raw_token` aliases) --
        the operator must capture it; subsequent reads only see the
        hashed value.
      operationId: registerAdminRunner
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRunnerRequest'
      responses:
        '201':
          description: Runner created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterRunnerResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/admin/runners/{id}:
    delete:
      tags: [public-admin]
      summary: Delete a v1 CI runner
      description: Removes the runner row. The runner will fail its next heartbeat once the row is gone. Auth scope is `acl.AdminRunners`.
      operationId: deleteAdminRunner
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/RunnerIDPath'
      responses:
        '204':
          description: Runner deleted.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/admin/runners/{id}/rotate-token:
    post:
      tags: [public-admin]
      summary: Rotate a runner's authentication token
      description: |
        Returns a new raw token EXACTLY ONCE; the previous token hash
        is invalidated atomically inside the UPDATE. The runner must
        re-authenticate with the new value on its next heartbeat or
        the controller will refuse it.
      operationId: rotateAdminRunnerToken
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/RunnerIDPath'
      responses:
        '200':
          description: New raw token (returned once).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RotateRunnerTokenResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/admin/audit:cicd:
    get:
      tags: [public-admin]
      summary: List CICDv2 audit events
      description: |
        Operator-only paginated, filterable view of the CICDv2 audit
        trail (`cicd_audit_events`). Filter by `actor_id` / `scope_kind`
        (`runner_host`, `runner_group`, `pipeline`, `job`, `secret`,
        `policy`) / `scope_id` / `action`. Auth: `acl.AdminAuditRead`.

        The colon `:cicd` is a literal path character (not a chi
        wildcard); the route is mounted with the
        `requireAdminSystemMiddleware` wrap inline so the leaf chi
        registration carries the rate-scope marker.
      operationId: listCICDAuditEvents
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: actor_id
          schema: { type: string, format: uuid }
        - in: query
          name: scope_kind
          schema:
            type: string
            enum: [runner_host, runner_group, pipeline, job, secret, policy]
        - in: query
          name: scope_id
          schema: { type: string, format: uuid }
        - in: query
          name: action
          schema: { type: string }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PerPageQuery'
      responses:
        '200':
          description: Paginated audit events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CICDAuditEventsListResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/admin/usage:cicd:
    get:
      tags: [public-admin]
      summary: Cross-tenant CICDv2 usage rollup
      description: |
        Aggregated CICDv2 usage by tenant and by hour. Source is the
        `cicd_usage_summary` rollup read via the
        `cicd.UsageSummarySource` seam. When the rollup table is
        absent the seam returns an empty envelope rather than a 500.
        Auth: `acl.AdminSystem` via `requireAdminSystemMiddleware`.
      operationId: getCICDUsage
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
          description: RFC3339 inclusive lower bound (default `to - 30d`).
        - in: query
          name: to
          schema: { type: string, format: date-time }
          description: RFC3339 exclusive upper bound (default now).
      responses:
        '200':
          description: Per-tenant + grand-total usage envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageCICDResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }

  # =============================================================
  # Public tenant API
  # =============================================================

  # GET and PATCH /api/v1/orgs/{owner}/cicd (operationIds
  # getTenantCICDFlag / patchTenantCICDFlag) were specified here as the
  # per-tenant `cicd_v2` feature-flag overlay, backed by the
  # `cicd_v2.tenant.<owner_uuid>` app-settings key. The overlay editor
  # was removed with the `cicd_v2` sunset; neither route is registered
  # on any current deployment and both answer 404. See the sunset note
  # in `info.description`. Deliberately not retained as `deprecated`
  # operations -- see that note for why.
  #
  # The similarly named `/api/v1/orgs/{owner}/cicd-policy` below is a
  # DIFFERENT, live surface: the per-org numeric policy overlay.

  /api/v1/orgs/{owner}/cicd-policy:
    get:
      tags: [public-tenant]
      summary: Read per-org CICDv2 numeric policy overlay
      description: |
        Reads the per-owner numeric CICDv2 policy overlay (the four
        cap fields `max_job_timeout` / `cpu_millis` / `memory_bytes` /
        `pids_max`, mirroring the cluster-wide `cicd.defaults.*` keys
        the admin surface installs). When no overlay row
        exists the response carries empty / zero values with
        `source: "default"`; when at least one row exists the
        response carries every field (zero-valued for absent ones)
        with `source: "explicit"`.

        Path note: the `cicd-policy` leaf, rather than a bare `cicd`
        leaf, is a leftover of routing history. The bare leaf was once
        occupied by the boolean `cicd_v2` feature-flag overlay, and chi
        cannot multiplex two handlers on the same (method, path) pair.
        The flag overlay has since been removed (see the sunset note in
        `info.description`), but this surface keeps the `cicd-policy`
        path, which is also what its per-repo and per-user siblings
        use. A bare `/api/v1/orgs/{owner}/cicd` is not routed and
        answers 404.

        Authz: instance admin holding `acl.AdminSystem` OR the
        owning user themselves (mirrors the tenant overlay
        posture; no `OrgAdmin` scope exists today).
      operationId: getOrgCICDPolicy
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
      responses:
        '200':
          description: Org-scoped CICDv2 policy envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgCICDPolicyResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [public-tenant]
      summary: Update per-org CICDv2 numeric policy overlay
      description: |
        Upserts one or more numeric policy fields for the named owner.
        Each field is optional; an omitted field is a no-op. An empty
        body returns 400. Unknown fields are rejected (the decoder
        uses `DisallowUnknownFields`).

        Validation pipeline (whole-batch, no DB writes until every
        supplied field clears both gates):

          1. Syntactic floor -- mirrors the
             `cicd.defaults.*` lower bounds (`max_job_timeout` >= 1m,
             `cpu_millis` >= 100, `memory_bytes` >= 134217728,
             `pids_max` >= 16). Violations return 400.
          2. App-cap ceiling -- reads the live cluster cap from
             `admin.Settings` (with documented defaults as a
             fallback) and rejects any value that exceeds the cap
             with 422 + `error_code: org_policy_exceeds_app_cap`.

        On success the handler emits a `cicd.policy.org.update`
        audit row carrying `actor_user_id`, `org_id`, and a
        `changed` sub-map of `{old, new}` per mutated key.

        Authz: instance admin holding `acl.AdminSystem` OR the
        owning user themselves.
      operationId: patchOrgCICDPolicy
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrgCICDPolicyPatchRequest'
      responses:
        '200':
          description: Updated org-scoped CICDv2 policy envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgCICDPolicyResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            Validation failure -- a supplied field value exceeds the
            cluster-wide app cap. Body carries
            `error_code: org_policy_exceeds_app_cap` alongside the
            human-readable message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelopeWithCode'
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/pipelines:
    get:
      tags: [public-tenant]
      summary: List the caller's 50 most recent pipelines across all owned repos
      description: Convenience cross-repo pipeline list scoped to repositories the caller owns. Bounded to the 50 most recent rows by created_at descending; pagination is not exposed today.
      operationId: listAllPipelines
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Pipeline list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GlobalPipeline'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines:
    get:
      tags: [public-tenant]
      summary: List pipelines for a repository
      description: Paginated pipeline list for the named repository. Uses the `page` / `per_page` pagination convention.
      operationId: listRepoPipelines
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PerPageQuery'
      responses:
        '200':
          description: Repo pipelines list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pipeline'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [public-tenant]
      summary: Trigger a new pipeline
      description: |
        Schedules a new pipeline on the supplied ref. The handler
        walks the documented config search path
        (`.ci/pipeline.yml` -> `.vetrix/pipeline.yml` -> `vetrix-ci.yml`)
        for the pipeline definition. Auth requires `acl.PermCIWrite`
        on the repository.
      operationId: triggerPipeline
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TriggerPipelineRequest'
      responses:
        '201':
          description: Pipeline scheduled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pipeline'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}:
    get:
      tags: [public-tenant]
      summary: Get a pipeline with its jobs
      description: Returns the pipeline plus its constituent pipeline_jobs. 404 when the pipeline or repo is not visible to the caller.
      operationId: getPipeline
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
      responses:
        '200':
          description: Pipeline detail (with jobs).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PipelineDetail'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}/cancel:
    post:
      tags: [public-tenant]
      summary: Cancel a pipeline
      description: 409 when the pipeline is already in a terminal state.
      operationId: cancelPipeline
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
      responses:
        '204':
          description: Pipeline cancellation accepted.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Pipeline already in terminal state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}/retry:
    post:
      tags: [public-tenant]
      summary: Retry a failed or cancelled pipeline
      description: Re-schedules the original commit SHA and ref through the discovered pipeline config. Only `failed` and `cancelled` source pipelines are retryable (409 otherwise). Auth requires `acl.PermCIWrite` on the repo.
      operationId: retryPipeline
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
      responses:
        '201':
          description: New pipeline scheduled as a retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pipeline'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Pipeline is not in a retryable (failed/cancelled) state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}/jobs/{jid}/logs:
    get:
      tags: [public-tenant]
      summary: Stream pipeline-job logs (SSE)
      description: |
        Server-Sent Events stream of historical + live log frames for
        the named job. Mounted with the `noWriteTimeout` middleware so
        the long-lived connection is not terminated by the global write
        timeout. Supports `Last-Event-ID` for resume per RFC 6202.
      operationId: streamPipelineJobLogs
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
        - $ref: '#/components/parameters/PipelineJobIDPath'
        - in: header
          name: Last-Event-ID
          required: false
          schema: { type: string }
      responses:
        '200':
          description: SSE stream (`text/event-stream`).
          content:
            text/event-stream:
              schema:
                type: string
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses:
    get:
      tags: [public-tenant]
      summary: List commit statuses for a commit SHA
      description: Returns the commit_statuses rows associated with the supplied commit SHA in the repository. Used by the merge-request UI to render the per-context status pills.
      operationId: listCommitStatuses
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - in: path
          name: sha
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Commit statuses.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CommitStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}/artifacts:
    get:
      tags: [public-tenant]
      summary: List artifacts for a pipeline
      description: Returns all pipeline_artifacts rows produced by jobs in the pipeline. Auth is the repo-visibility gate; no extra scope.
      operationId: listPipelineArtifacts
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
      responses:
        '200':
          description: Artifact list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Artifact'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /api/v1/repos/{owner}/{repo}/pipelines/{id}/artifacts/{name}:
    get:
      tags: [public-tenant]
      summary: Get a specific artifact by name
      description: Returns one artifact by its `(pipeline_id, name)` tuple. 404 when no matching artifact exists.
      operationId: getPipelineArtifact
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OwnerPath'
        - $ref: '#/components/parameters/RepoPath'
        - $ref: '#/components/parameters/PipelineIDPath'
        - in: path
          name: name
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Artifact metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Artifact'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # Additional public-tenant CICDv2 paths under
  # /api/v1/repos/{owner}/{repo}/cicd* (secrets, variables, triggers,
  # runner-group selectors, etc.) are not yet part of this spec; those
  # paths will be added when that surface lands.

  # =============================================================
  # Job-side API (VETRIX_JOB_TOKEN bearer)
  # =============================================================

  /api/v1/jobs/{id}/artifacts:
    post:
      tags: [job-side]
      summary: Push a job artifact
      description: |
        The in-DinD runner pushes artifact metadata back to the control
        plane. Auth uses `VETRIX_JOB_TOKEN` (typ=job-token JWT minted
        by `auth.IssueJobTokenForJob`). The token's `job_id` claim
        MUST equal the path `{id}`; the token's `pipeline_id` /
        `repo_id` claims MUST match the persisted job row. Requires the
        `registry:write` permission on the token.

        v1 (legacy) payloads that omit the v2 fields (`retention_days`,
        `paths`) are accepted unchanged -- v1 callers do not regress.
      operationId: pushJobArtifact
      security:
        - jobTokenAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath2'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JobArtifactPushRequest'
      responses:
        '201':
          description: Artifact recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Artifact'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/jobs/{id}/cache:url:
    get:
      tags: [job-side]
      summary: Presigned GET URL for restoring the job cache
      description: |
        Returns a presigned S3/MinIO GET URL the runner uses to restore
        its build cache at job start. The object key is derived ONLY
        from the token claims via `internal/cicd/cachekey`; the path
        `{id}` is asserted equal to the token's `job_id`. Auth uses
        `VETRIX_JOB_TOKEN`. URL expiry is clamped server-side to <= 1h.
      operationId: getJobCacheURL
      security:
        - jobTokenAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath2'
      responses:
        '200':
          description: Presigned GET URL envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCacheRestoreURLResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    put:
      tags: [job-side]
      summary: Presigned PUT URL for saving the job cache
      description: |
        Returns a presigned S3/MinIO PUT URL the runner uses to upload
        its build cache archive. The object key is derived ONLY from
        the server-side `pipeline_jobs JOIN pipelines` row via
        `internal/cicd/cachekey` (the consolidated
        `cachekey.ObjectKey(repoID, pipelineID, jobID)` resolver) and
        cross-checked against the token claims; the presigned PUT itself
        is minted by `internal/cicd/cachestore`. Quota metered via
        `ratelimit.RequestLimiter` on the `api.expensive` scope
        (per-repo). Expiry is clamped to <= 1h.
      operationId: putJobCacheURL
      security:
        - jobTokenAuth: []
      parameters:
        - $ref: '#/components/parameters/JobIDPath2'
      responses:
        '200':
          description: Presigned PUT URL envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCacheSaveURLResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429':
          description: Per-repo cache-URL quota exceeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
          headers:
            Retry-After:
              schema: { type: integer }
        '502':
          description: Could not mint cache URL (presign failure).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

# =============================================================
# Components
# =============================================================

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Standard Vetrix bearer JWT (session / access token).
        `auth.Service.Verify` rejects tokens of `typ=job-token` --
        those are accepted only by job-side endpoints
        (`jobTokenAuth`).

    jobTokenAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        `VETRIX_JOB_TOKEN` -- the typ=job-token JWT minted at dispatch
        time by `auth.IssueJobTokenForJob`. Carries
        `repo_id` + `pipeline_id` + `job_id` claims plus a permissions
        list (`registry:write` for artifact / cache push). The runner
        passes this in `Authorization: Bearer <token>` (also accepted
        as `Authorization: Basic base64(user:token)` mirroring the
        docker convention the registry uses).

    mtlsAuth:
      type: mutualTLS
      description: |
        Mutual TLS using the CICDv2 private CA (see
        `internal/cicdv2/mtls`). The /internal/* listener pins
        `tls.RequireAndVerifyClientCert` and the application layer
        cross-checks the peer cert `Subject.CommonName` against the
        registered host FQDN. Documentation-only here -- no OpenAPI
        request shape carries the TLS handshake.

  parameters:
    PageQuery:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }
      description: 1-based page number; values < 1 fall back to 1.
    PerPageQuery:
      in: query
      name: per_page
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      description: >-
        Page size (valid range 1-100, default 25). Out-of-range handling is
        endpoint-specific: the repository pipeline list falls back to the
        default, while the admin CICDv2 lists clamp to the nearest bound.
    LimitQuery:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100 }
      description: Maximum number of results to return.
    OffsetQuery:
      in: query
      name: offset
      schema: { type: integer, minimum: 0 }
      description: Zero-based offset into the result set.
    OwnerPath:
      in: path
      name: owner
      required: true
      schema: { type: string }
      description: Owning user or organisation username (URL slug).
    RepoPath:
      in: path
      name: repo
      required: true
      schema: { type: string }
      description: Repository name (URL slug).
    HostIDPath:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: Runner host UUID.
    RunnerIDPath:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: CI runner UUID.
    PipelineIDPath:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: Pipeline UUID.
    PipelineJobIDPath:
      in: path
      name: jid
      required: true
      schema: { type: string, format: uuid }
      description: Pipeline-job UUID.
    JobIDPath:
      in: path
      name: job_id
      required: true
      schema: { type: string, format: uuid }
      description: Job UUID (host-pool internal routes use `job_id`).
    JobIDPath2:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: Job UUID (public job-side routes use `id`).

  responses:
    BadRequest:
      description: Malformed request (invalid JSON / missing required field / unknown field).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Unauthorized:
      description: Missing or invalid credentials (no claims; JWT expired; unknown token).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Forbidden:
      description: Authenticated but lacking the required scope / permission.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    UnprocessableEntity:
      description: Validation failure (e.g. pipeline YAML invalid, value out of bounds).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    InternalError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ServiceUnavailable:
      description: |
        Feature staged but the backing dependency is not wired on this
        deployment (e.g. cache presigner / chunk store / host-pool
        store unconfigured).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'

  schemas:

    ErrorEnvelope:
      type: object
      description: |
        Standard JSON error envelope written by `respondError` and
        `respondErrorDetail`. `detail` is set only by handlers that
        explicitly call `respondErrorDetail` (e.g. malformed JSON
        body).
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error summary.
        detail:
          type: string
          description: Optional machine-or-operator-readable secondary detail.

    # ---- Internal host-pool ------------------------------------

    HostPoolRegisterRequest:
      type: object
      required:
        - host_attach_token
        - fqdn
        - host_class
        - capacity_cpu
        - capacity_mem_bytes
      properties:
        host_attach_token:
          type: string
          description: Single-use plaintext invite minted by an operator.
        fqdn:
          type: string
          description: Fully-qualified hostname (must match the peer-cert CN).
        host_class:
          type: string
          example: linux-amd64
        capacity_cpu:
          type: integer
          minimum: 1
          description: Total CPU capacity in millicores (or as the host accounting layer defines).
        capacity_mem_bytes:
          type: integer
          format: int64
          minimum: 1
        tags:
          type: array
          items: { type: string }
        version:
          type: string
          description: Host-agent build version (e.g. `0.1.0`).

    HostPoolRegisterResponse:
      type: object
      required: [host_id, state]
      properties:
        host_id:
          type: string
          format: uuid
        state:
          type: string
          enum: [registering, online, draining, drained, decommissioned]
          example: registering
        tenant_pin_id:
          type: string
          format: uuid
          nullable: true
          description: Set when the host is pinned to a single tenant; null for the shared pool.

    HostPoolHeartbeatRequest:
      type: object
      required: [host_id, cpu_used, mem_used]
      properties:
        host_id:
          type: string
          format: uuid
        cpu_used:
          type: integer
          minimum: 0
          description: In-use CPU (millicores).
        mem_used:
          type: integer
          format: int64
          minimum: 0
          description: In-use memory (bytes).

    HostPoolJobSpec:
      type: object
      description: |
        JobSpec carried in the dispatch RPC body. Unknown fields are
        tolerated for forward-compat with later JobSpec extensions.
      required: [job_id, image, commands]
      properties:
        job_id:
          type: string
          format: uuid
          description: Must equal the path `{job_id}`.
        image:
          type: string
          description: Inner build image the executor runs.
        outer_image:
          type: string
          description: Outer/host image the host-agent uses.
        commands:
          type: array
          minItems: 1
          items: { type: string, minLength: 1 }
        tags:
          type: array
          items: { type: string }

    HostPoolDispatchAck:
      type: object
      required: [job_id, host_id]
      properties:
        job_id: { type: string, format: uuid }
        host_id:
          type: string
          description: Peer-cert CN the dispatch was recorded against.

    HostPoolCompleteJobRequest:
      type: object
      required: [state]
      properties:
        state:
          type: string
          enum: [succeeded, success, failed, cancelled]
          description: |
            Terminal job state. `succeeded` is the host-agent
            spelling and maps to the DB-side `success`; `success`
            is also accepted verbatim.
        exit_code:
          type: integer
          nullable: true
          description: Process exit code (optional; leaves `exit_code` NULL when absent).
        duration_ms:
          type: integer
          format: int64
          nullable: true
          description: Wall-clock duration in milliseconds (audit-only).
        host_id:
          type: string
          format: uuid
          description: Host-agent UUID (will be required once mTLS SAN binding is wired through).

    HostPoolLogsResponse:
      type: object
      required: [job_id, persisted_bytes, received_lines, dropped_bytes]
      properties:
        job_id: { type: string, format: uuid }
        persisted_bytes:
          type: integer
          format: int64
          description: Cumulative bytes the controller has persisted (= the agent's new high-water mark).
        received_lines:
          type: integer
          description: Number of log lines accepted in this request.
        dropped_bytes:
          type: integer
          format: int64
          description: Bytes silently dropped because Last-Event-ID indicated they were already persisted.

    # ---- Admin: CICD policy defaults --------------------------

    CICDDefaultsResponse:
      type: object
      description: Typed cluster-wide CICDv2 policy defaults.
      required: [max_job_timeout, cpu_millis, memory_bytes, pids_max]
      properties:
        max_job_timeout:
          type: string
          example: 6h
          description: Go-duration formatted maximum job runtime.
        cpu_millis:
          type: integer
          example: 2000
        memory_bytes:
          type: integer
          format: int64
          example: 2147483648
        pids_max:
          type: integer
          example: 1024

    CICDDefaultsPatchRequest:
      type: object
      description: |
        Per-field patch body for the cluster-wide CICDv2 policy
        defaults. All four fields are optional; an omitted field is a
        no-op. A body supplying none of them returns 400. Values are
        checked against the bounded validators registered on the
        `cicd.defaults.*` keys before anything is written -- out of
        bounds is 422, unparseable is 400.

        `additionalProperties: false` mirrors the handler, which
        decodes with unknown fields disallowed. A key outside this set
        -- notably `value` -- is a 400, not a silently ignored field
        and not a feature-flag write.
      additionalProperties: false
      properties:
        max_job_timeout:
          type: string
          example: 6h
          description: >-
            Go-duration formatted maximum job runtime, e.g. `6h`. Sent
            as a string; the other three fields are integers.
        cpu_millis: { type: integer }
        memory_bytes: { type: integer, format: int64 }
        pids_max: { type: integer }

    # CICDFlagResponse and CICDFlagPatchRequest -- the `{scope, value,
    # source}` envelope and the `{"value": <bool string>}` body of the
    # `cicd_v2` feature flag -- were defined here. Both were removed
    # with the operations that referenced them; no live endpoint accepts
    # or returns either shape. See the sunset note in
    # `info.description`.

    # ---- Tenant: org-scoped CICDv2 policy -----------

    OrgCICDPolicyResponse:
      type: object
      description: |
        Org-scoped numeric CICDv2 policy envelope. Every
        cap field is always present; absent overlay rows surface as
        the field's zero value (`""` for `max_job_timeout`, `0` for
        the integer fields). `source` is `explicit` when any overlay
        row exists for the owner; `default` otherwise.
      required:
        - max_job_timeout
        - cpu_millis
        - memory_bytes
        - pids_max
        - source
      properties:
        max_job_timeout:
          type: string
          description: |
            Go-duration formatted maximum job runtime (e.g. `6h`,
            `30m`). Empty string when no overlay row exists.
          example: 6h
        cpu_millis:
          type: integer
          description: Per-job CPU budget in milli-CPUs.
          example: 2000
        memory_bytes:
          type: integer
          format: int64
          description: Per-job memory budget in bytes.
          example: 2147483648
        pids_max:
          type: integer
          description: Per-job max PIDs.
          example: 1024
        source:
          type: string
          enum: [default, explicit]
          description: |
            `explicit` when at least one org overlay row exists;
            `default` when no rows exist for this owner.
        owner:
          type: string
          description: Owner username (URL slug); echoed on every response.

    OrgCICDPolicyPatchRequest:
      type: object
      description: |
        All fields optional; an omitted field is a no-op. An empty
        body returns 400. Unknown fields are rejected
        (`DisallowUnknownFields`). Each supplied value is validated
        against the syntactic floor (e.g. `cpu_millis` >=
        100, `max_job_timeout` >= 1m) and the live cluster app cap;
        cap exceedances return 422 +
        `error_code: org_policy_exceeds_app_cap`.
      additionalProperties: false
      properties:
        max_job_timeout:
          type: string
          description: Go-duration formatted maximum job runtime.
          example: 4h
        cpu_millis:
          type: integer
          example: 1500
        memory_bytes:
          type: integer
          format: int64
          example: 1073741824
        pids_max:
          type: integer
          example: 512

    ErrorEnvelopeWithCode:
      type: object
      description: |
        Extension of `ErrorEnvelope` emitted by `respondErrorCode`.
        Carries a stable machine-readable `error_code` alongside the
        human-readable `error` message. The frontend pattern-matches
        on `error_code` rather than the message copy.
      required: [error, error_code]
      properties:
        error:
          type: string
          description: Human-readable error summary.
        error_code:
          type: string
          description: |
            Stable machine-readable error identifier (e.g.
            `org_policy_exceeds_app_cap`).

    # ---- Admin: runner hosts (CICDv2) -------------------------

    RunnerHost:
      type: object
      required:
        - id
        - fqdn
        - host_class
        - capacity_cpu
        - capacity_mem_bytes
        - in_use_cpu
        - in_use_mem_bytes
        - tags
        - state
        - version
        - created_at
        - healthy
      properties:
        id: { type: string, format: uuid }
        fqdn: { type: string }
        host_class: { type: string }
        capacity_cpu: { type: integer }
        capacity_mem_bytes: { type: integer, format: int64 }
        in_use_cpu: { type: integer }
        in_use_mem_bytes: { type: integer, format: int64 }
        tags:
          type: array
          items: { type: string }
        state:
          type: string
          enum: [registering, online, draining, drained, decommissioned]
        version: { type: string }
        last_heartbeat_at:
          type: string
          format: date-time
          nullable: true
        tenant_pin_id:
          type: string
          format: uuid
          nullable: true
        created_at: { type: string, format: date-time }
        healthy:
          type: boolean
          description: Derived from `(state == online) AND (last_heartbeat_at within threshold)`.

    RunnerHostsListResponse:
      type: object
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/RunnerHost'
        total: { type: integer }
        page: { type: integer }
        per_page: { type: integer }

    HostDrainStatus:
      type: object
      required: [host_id, state, in_flight_jobs, drained]
      properties:
        host_id: { type: string, format: uuid }
        state:
          type: string
          enum: [online, draining, drained]
        in_flight_jobs: { type: integer }
        drained: { type: boolean }

    # ---- Admin: v1 runners ------------------------------------

    AdminRunner:
      type: object
      required: [id, name, tags, status, last_seen_at, registered_at, isolation_kind, ssh_capable]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        tags:
          type: array
          items: { type: string }
        status: { type: string }
        last_seen_at:
          type: string
          format: date-time
          nullable: true
        registered_at: { type: string, format: date-time }
        group_id: { type: string, format: uuid }
        isolation_kind: { type: string }
        ssh_capable: { type: boolean }
        arch: { type: string }
        os: { type: string }
        cpu_millis: { type: integer }
        mem_mib: { type: integer }

    RegisterRunnerRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }
        tags:
          type: array
          items: { type: string }

    RegisterRunnerResponse:
      type: object
      required: [token, raw_token, runner]
      properties:
        token:
          type: string
          description: Legacy alias of `raw_token`.
        raw_token:
          type: string
          description: |
            Bearer token the runner uses to authenticate.
            **Returned exactly once** -- the response is the only
            opportunity to capture it.
        runner:
          $ref: '#/components/schemas/AdminRunner'

    RotateRunnerTokenResponse:
      type: object
      required: [raw_token]
      properties:
        raw_token:
          type: string
          description: |
            New bearer token. The previous token's hash is invalidated
            atomically; the runner must re-authenticate with the new
            value on its next call.

    # ---- Admin: audit ----------------------------------------

    CICDAuditEvent:
      type: object
      required: [id, occurred_at, scope_kind, scope_id, action]
      properties:
        id: { type: string, format: uuid }
        occurred_at: { type: string, format: date-time }
        actor_id:
          type: string
          format: uuid
          nullable: true
        scope_kind:
          type: string
          enum: [runner_host, runner_group, pipeline, job, secret, policy]
        scope_id: { type: string, format: uuid }
        action: { type: string }
        payload:
          type: object
          additionalProperties: true
          description: Free-form action-specific JSON.

    CICDAuditEventsListResponse:
      type: object
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CICDAuditEvent'
        total: { type: integer }
        page: { type: integer }
        per_page: { type: integer }

    # ---- Admin: usage ----------------------------------------

    UsageHourBucket:
      type: object
      required: [hour_bucket, pipeline_runs, job_runs, build_minutes]
      properties:
        hour_bucket:
          type: string
          format: date-time
          description: RFC3339 timestamp of the hour-aligned bucket start.
        pipeline_runs: { type: integer, format: int64 }
        job_runs: { type: integer, format: int64 }
        build_minutes: { type: integer, format: int64 }

    UsageTenantGroup:
      type: object
      required: [tenant_id, pipeline_runs, job_runs, build_minutes, hours]
      properties:
        tenant_id: { type: string, format: uuid }
        tenant_slug: { type: string }
        pipeline_runs: { type: integer, format: int64 }
        job_runs: { type: integer, format: int64 }
        build_minutes: { type: integer, format: int64 }
        hours:
          type: array
          items:
            $ref: '#/components/schemas/UsageHourBucket'

    UsageCICDResponse:
      type: object
      required: [window_from, window_to, tenants, totals]
      properties:
        window_from: { type: string, format: date-time }
        window_to: { type: string, format: date-time }
        tenants:
          type: array
          items:
            $ref: '#/components/schemas/UsageTenantGroup'
        totals:
          type: object
          required: [pipeline_runs, job_runs, build_minutes]
          properties:
            pipeline_runs: { type: integer, format: int64 }
            job_runs: { type: integer, format: int64 }
            build_minutes: { type: integer, format: int64 }

    # ---- Pipelines (public tenant) ----------------------------

    Pipeline:
      type: object
      required: [id, repo_id, commit_sha, ref, trigger, state, created_at]
      properties:
        id: { type: string, format: uuid }
        repo_id: { type: string, format: uuid }
        commit_sha: { type: string }
        ref: { type: string }
        trigger: { type: string, example: manual }
        state:
          type: string
          enum: [pending, running, success, failed, cancelled]
        created_at: { type: string, format: date-time }
        finished_at:
          type: string
          format: date-time
          nullable: true

    GlobalPipeline:
      allOf:
        - $ref: '#/components/schemas/Pipeline'
        - type: object
          required: [owner, repo_name]
          properties:
            owner: { type: string }
            repo_name: { type: string }

    PipelineJob:
      type: object
      required: [id, pipeline_id, name, stage, image, state]
      properties:
        id: { type: string, format: uuid }
        pipeline_id: { type: string, format: uuid }
        name: { type: string }
        stage: { type: string }
        image: { type: string }
        state:
          type: string
          enum: [pending, running, success, failed, cancelled]
        runner_id: { type: string, format: uuid }
        exit_code: { type: integer, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
        allow_failure:
          type: boolean
          description: >-
            When set, the job's failure is tolerated: it stays in state
            `failed` but does not fail the pipeline. Absent when the job does
            not set the flag.
        status_reason:
          type: string
          description: >-
            Why a terminal job ended, distinct from `state` (for example
            `timed_out` for a job the runner killed for overrunning its
            effective timeout). Absent when no reason was recorded.
        resolved_timeout_seconds:
          type: integer
          nullable: true
          description: >-
            Resolved per-job timeout, in seconds, that the worker computed and
            enforced as the execution deadline. Absent on jobs the worker has
            not stamped.
        host_id:
          type: string
          format: uuid
          description: CICDv2 runner host that executed the job. Absent on v1-shaped jobs.
        outer_image:
          type: string
          description: Outer/host image the runner used to run the job. Absent on v1-shaped jobs.
        network_mode:
          type: string
          description: Container network mode the runner applied. Absent on v1-shaped jobs.
        cache_hit:
          type: boolean
          nullable: true
          description: >-
            Whether the declared job cache was restored. Three-state: absent
            (no cache declared), `true` (restored), or `false` (declared but
            missed).
        cache_key:
          type: string
          description: Cache key the job resolved. Absent on v1-shaped jobs.
        cold_start:
          type: boolean
          nullable: true
          description: >-
            Whether the sandbox was cold-started rather than reused from a warm
            pool. Absent on v1-shaped jobs.
        cgroup_limits:
          allOf:
            - $ref: '#/components/schemas/CgroupLimits'
          description: >-
            CICDv2 cgroup resource caps the runner applied to the job sandbox.
            Absent on v1-shaped jobs.

    CgroupLimits:
      type: object
      description: >-
        Cgroup resource caps applied to a CICDv2 job sandbox. Each field is
        present only when the corresponding cap was set.
      properties:
        cpus: { type: number }
        memory_bytes: { type: integer, format: int64 }
        memory_swap_bytes: { type: integer, format: int64 }
        pids: { type: integer, format: int64 }
        blkio_weight: { type: integer }

    PipelineDetail:
      allOf:
        - $ref: '#/components/schemas/Pipeline'
        - type: object
          required: [jobs]
          properties:
            jobs:
              type: array
              items:
                $ref: '#/components/schemas/PipelineJob'

    TriggerPipelineRequest:
      type: object
      required: [ref]
      properties:
        ref:
          type: string
          description: Git ref (branch / tag / commit) to schedule the pipeline against.

    Artifact:
      type: object
      required: [id, pipeline_id, job_id, name, size_bytes, sha256, created_at]
      properties:
        id: { type: string, format: uuid }
        pipeline_id: { type: string, format: uuid }
        job_id: { type: string, format: uuid }
        name: { type: string }
        size_bytes: { type: integer, format: int64 }
        sha256: { type: string }
        created_at: { type: string, format: date-time }
        retention_expires_at:
          type: string
          format: date-time
          description: CICDv2 field; absent on v1 artifacts.
        paths:
          type: array
          items: { type: string }
          description: CICDv2 field; absent on v1 artifacts.
        download_url:
          type: string
          description: >-
            Relative path to the artifact content route
            (`GET .../artifacts/{id}/content`) that streams the raw bytes.
            Present on the repository-scoped list and detail responses; absent
            on the job-side push response.

    CommitStatus:
      type: object
      required: [id, commit_sha, context, state, updated_at]
      properties:
        id: { type: string, format: uuid }
        commit_sha: { type: string }
        context: { type: string }
        state: { type: string }
        description: { type: string }
        target_url: { type: string, format: uri }
        updated_at: { type: string, format: date-time }

    # ---- Job-side --------------------------------------------

    JobArtifactPushRequest:
      type: object
      required: [name, storage_key, size_bytes]
      properties:
        name: { type: string }
        storage_key: { type: string }
        size_bytes: { type: integer, format: int64, minimum: 0 }
        sha256: { type: string }
        retention_days:
          type: integer
          minimum: 0
          description: Days to retain the artifact (CICDv2 field).
        retention:
          type: integer
          minimum: 0
          description: Back-compat alias for `retention_days`.
        paths:
          type: array
          items: { type: string }
          description: |
            Source paths from the runner workspace that produced the
            artifact (CICDv2 field, optional).

    JobCacheRestoreURLResponse:
      type: object
      required: [url, expires_at, expires_in_seconds, object_key]
      properties:
        url:
          type: string
          format: uri
          description: Presigned S3/MinIO GET URL.
        expires_at:
          type: string
          format: date-time
        expires_in_seconds: { type: integer }
        object_key: { type: string }

    JobCacheSaveURLResponse:
      type: object
      required: [method, url, expires_at, object_key]
      properties:
        method:
          type: string
          enum: [PUT]
        url:
          type: string
          format: uri
          description: Presigned S3/MinIO PUT URL.
        expires_at:
          type: string
          format: date-time
        object_key: { type: string }