openapi: 3.1.0
info:
  title: Plumber Platform API
  version: 2.9.0
  description: |-
    The HTTP API of the Plumber Platform, served by your Plumber instance under `/api/v1`.

    **Authentication.** Every operation needs a logged-in session (the `sessionCookie` scheme) unless it says otherwise; a few read operations also accept a personal API token as a bearer token. The role the operation requires (Member, Maintainer, OrgAdmin) is stated on each operation.

    **Lists.** An offset-paginated list answers `{items, page, per_page, total}`; a keyset-paginated one answers `{items, total, next_cursor}` and takes an opaque `after` cursor. `per_page` is clamped to its documented maximum. Every other query parameter is validated: an unknown parameter name, or a value outside a parameter's vocabulary, answers `400` naming it (`severity` is matched case-insensitively, every other vocabulary is case-sensitive).

    **Requests.** Unknown fields in a request body are ignored.

    **Honest states.** A missing or degraded analysis result is reported as such (`not evaluable`, `stale`, `degraded`); the platform never fabricates a score or a verdict.

    `version` is the Platform release this contract was published with (`0.0.0-dev` in the repository and in an unreleased build).
servers:
  - url: https://{instance}/api/v1
    variables:
      instance:
        default: plumber.example.com
paths:
  /healthz:
    get:
      operationId: healthz
      summary: Liveness probe (unauthenticated, for a Kubernetes-style orchestrator)
      description: |-
        Answers 200 as long as this process is up and serving HTTP. It consults no dependency
        (not the database, not Redis), so it only says whether the process is wedged, never
        whether it can currently serve: that is `/readyz`'s job.
      security: []
      responses:
        '200':
          description: The process is serving HTTP. This is the only status this endpoint returns.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }

  /readyz:
    get:
      operationId: readyz
      summary: Readiness probe (unauthenticated, for a Kubernetes-style orchestrator)
      description: |-
        Answers 200 only when the database is reachable. A 503 means this instance cannot serve
        reads or writes and should be pulled from the load-balancing pool.

        Redis reachability never affects the HTTP status or the `status` field; the payload still
        names `redis` (`ok` / `unreachable`) so monitoring can see it.

        `issuers` is a configuration warning for the operator, not a readiness fact: `ok` when
        every configured git instance can ingest pushes, `<n> instance(s) without a base URL
        cannot ingest` when some cannot, `no configured instance, nothing can ingest` before any
        instance is configured, or `unknown` when the check could not be read. It carries a
        count, never an instance name, it never flips the HTTP status or the `status` field, and
        it is present on the 503 body too.
      security: []
      responses:
        '200':
          description: The database is reachable. `redis` and `issuers` name their own state but never affect this status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  db: { type: string, enum: [ok] }
                  redis: { type: string, enum: [ok, unreachable] }
                  issuers: { type: string }
        '503':
          description: The database is unreachable; pull this instance from the pool.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [not_ready] }
                  db: { type: string, enum: [unreachable] }
                  redis: { type: string, enum: [ok, unreachable] }
                  issuers: { type: string }

  /pushes:
    post:
      operationId: createPush
      summary: Ingest a CLI analysis result (one push carries all policies' results for one run)
      description: |-
        The CLI pushes at the end of a job, authenticated by native CI OIDC. Project identity is taken from the verified OIDC claims, never from the body.

        The server, in order:

        - Verifies OIDC.
        - Applies an idempotency guard (job id) and an ordering guard (pipeline id + timestamps; the displayed result never regresses).
        - Accepts each policy score.
        - Updates freshness.
        - Emits an SSE `project.update` event.

        **Forward-tolerant**: unknown fields are ignored.
      security:
        - ciOIDC: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Push' }
      responses:
        '201':
          description: Ingested (new run); freshness updated, SSE emitted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushAccepted' }
        '200':
          description: >
            Idempotent replay of a job already ingested (same job id): upserted, no duplicate, no
            regression. A replay of a job whose run first landed out of order keeps answering 202
            `kind: out_of_order` with `evaluated: false`. Also answered for `kind: re_check`, the
            platform's own re-check of an existing run: that run's results and score are updated
            in place, `gate.evaluated` is true, and `freshness` reports the
            project's current `fresh` state while the displayed run (score, deployment_status) is
            left untouched.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushAccepted' }
        '202':
          description: >
            Accepted but recorded as out-of-order (an older pipeline landed after a newer one):
            stored in history, the displayed result does not regress. Same PushAccepted body shape
            as 200/201, with `recomputed` false and `gate.evaluated` false with
            `gate.reason: out_of_order`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushAccepted' }
        '400':
          description: >-
            The request body could not be read (the client disconnected before sending the full
            body). A body that was fully read and then failed validation is a `422`; a body that
            is too large is a `413`, checked first. Project state unchanged.
        '401':
          description: OIDC verification failed (missing/invalid token, wrong aud, unknown issuer). Project state unchanged.
        '413':
          description: >-
            The pushed body exceeds the size limit (16M). Checked both against a `Content-Length`
            header over the limit and against the body's actual byte count while reading, so a
            chunked or under-reported request also gets `413`, never a `400`. Project state
            unchanged.
        '415':
          description: >-
            `Content-Type` is not `application/json`. The media type is parsed, not prefix-matched:
            `application/json; charset=utf-8` is accepted, `application/json-patch+json` is
            refused. Checked after OIDC verification and before the body is read. Project state
            unchanged.
        '422':
          description: |-
            The envelope is invalid. Rejected here:

            - A missing `schema_version`, or an empty `results` array, unless the push carries the
              nothing-evaluated marker (`evaluation.nothing_evaluated: true`), which makes an empty
              result list a legitimate run. A push that sets the marker and also carries a
              non-empty `results` array is rejected too.
            - A finding's `data` or a policy result's `effective_config` nesting objects or arrays
              deeper than 100 levels.
            - An object inside a finding's `data` or a policy result's `effective_config` that
              references a CI/CD variable by name (`variableName` / `variable_name`) and also
              carries a value-shaped sibling key (`value`, `variableValue` / `variable_value`,
              `resolvedValue` / `resolved_value`, `envValue` / `env_value`) must declare that
              value's provenance on the same object via `valueProvenance` / `value_provenance`,
              with exactly one of `settings_secret` (a CI/CD variable the provider marks sensitive:
              GitLab hidden/masked, GitHub Secret), `settings_plain` (a CI/CD variable that is
              neither hidden nor masked) or `ci_file` (a literal written directly in the CI
              configuration, e.g. a `variables:` / `env:` / `with:` entry). A sensitive value is
              never accepted, declared or not. A value whose provenance is absent, not an exact
              match of one of the three tiers (including wrong casing), or over the per-value byte
              cap is rejected; only `settings_plain` or `ci_file`, within the cap, is stored. A
              plain `variables:` map with no variable-reference key is unaffected.
            - The optional `bom` section breaching one of its bounds: over 500 includes, 500
              images or 500 jobs, over 500 jobs on one image, over 64 services or 32 runner tags
              on one job, a string over 512 runes (1024 for a remote include URL), over 50
              advisories on one include, or a stored document over 256 KiB; or a `bom` with no
              `version`, or a `version` other than 1. The whole push is refused, never the section
              dropped.

            Every rejection returns the same generic message, with one exception: a variable value
            declaring no provenance (the shape a CLI without provenance support produces) answers
            "variable value at `<path>` declares no valueProvenance; a CLI version supporting
            provenance declarations is required for projects reporting variable values", where
            `<path>` is a fixed label and the pushed value is never echoed.
        '429':
          description: >-
            The rate limiter throttled this source IP. It is checked before OIDC verification, so
            an unauthenticated caller can get it too. `Retry-After` (seconds) says how long until
            the window frees at least one slot. The default budget is 600 pushes per minute per
            source IP.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller's own window frees at least one slot.
        '500':
          description: >-
            An internal failure persisting the push (e.g. a database error). Never a client-caused
            rejection: nothing about the guards, the ordering or the envelope can be concluded from
            it, and it carries no diagnostic body.
  /projects/{project_path}/context:
    get:
      operationId: getProjectContext
      summary: Resolved policy set + data collection snapshot for a project (one call, at analysis time)
      description: |-
        Served to the CLI (Bearer OIDC). One call returns the resolved policy set plus the data collection snapshot the CLI needs to evaluate certain controls.

        - The resolved policy set is never empty (falls back to [Plumber default]).
        - `snapshot.data` holds the collected project settings (branch protection, MR approval rules, CI variables) and may include `merged_yaml`, the resolved CI configuration, with an optional authoritative override from the CLI.
        - `snapshot.collected_at` says when the data collection snapshot was collected; it may be a cached read rather than a live call to the provider.
        - `snapshot.last_attempt_at` / `snapshot.last_attempt_outcome` say when the platform last tried to collect and how that try ended. Both are absent when no pass has ever run for the project.
        - An authenticated project always gets 200, even with no prior activity: the response is then the derived [Plumber default]. There is no 404 for the OIDC-attributed project.
      security:
        - ciOIDC: []
      parameters:
        - name: project_path
          in: path
          required: true
          schema: { type: string }
          description: 'Provider project path or opaque id.'
      responses:
        '200':
          description: Resolved context (never empty; falls back to [Plumber default] when no config is assigned yet).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectContext' }
        '400':
          description: 'Malformed project path or id parameter.'
        '401': { description: OIDC verification failed. }
        '403':
          description: 'The verified OIDC claim does not match {project_path}: a project may only read its own context.'
        '500': { description: 'An internal failure resolving the project context (e.g. a database error). Never a client-caused rejection.' }

  /projects/{project_path}/resolved-config:
    post:
      operationId: resolveProjectConfig
      summary: Resolve a project's CI configuration (bounded, cached by digest)
      description: |-
        Served to the CLI when its own computed CI-config digest diverges from the data collection snapshot's anchor. Self-only, like /context: the verified OIDC claim must match {project_path}. This is the only synchronous provider call a CLI request may trigger.

        - Cache hit on (project, config_digest, digest_version): returns the stored merged_yaml immediately, with no provider call.
        - Cache miss: concurrent identical requests share one resolution. Resolution is bounded by a timebox and a per-instance concurrency cap; exceeding either returns a typed 503, never a block and never a queue that outlives the caller's job.
        - The platform's own recomputed digest is authoritative for the cache key: the response always carries the platform-computed digest, and a mismatch with the request's config_digest is never an error.
        - `config_digest` / `digest_version` may be absent from the request (the CLI's own digest computation aborted, e.g. past the 50-file traversal limit or on a read failure). The platform then skips the cache lookup, resolves, and returns the result uncached, with `config_digest` / `digest_version` absent from the response too. They are also absent from the response alone when the platform's own recomputation aborts for the same reason: the merge still succeeds, but the result is not cached and resolves fresh on every run.
        - An invalid CI config (the provider reports the merge as invalid) is a user error, not a resolution failure: 200 with `valid: false` and a possibly-empty merged_yaml, never cached.
      security:
        - ciOIDC: []
      parameters:
        - name: project_path
          in: path
          required: true
          schema: { type: string }
          description: 'Provider project path or opaque id.'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResolveConfigRequest' }
      responses:
        '200':
          description: Resolved, from the cache or freshly; `source` says which.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ResolvedConfig' }
        '400':
          description: 'Malformed project path or id parameter.'
        '401': { description: OIDC verification failed. }
        '403':
          description: 'The verified OIDC claim does not match {project_path}: a project may only resolve its own configuration.'
        '500': { description: 'An internal failure resolving the CI config outside the 503 cases (e.g. a database error persisting the cache entry).' }
        '503':
          description: 'Resolution unavailable: the timebox or the per-instance concurrency cap was exceeded, the git host is unreachable, or no org token is configured for this instance. The CLI treats this as `not_evaluable` for merged-yaml-dependent controls; it never blocks and never fabricates a result.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ResolveUnavailable' }

  /events:
    get:
      operationId: streamEvents
      summary: Server-Sent Events stream (one per client; client-side filtering)
      description: |-
        SSE stream. One stream per client; the client filters.

        - Six event names. `project.update`: the project status dimensions, filtered to the
          projects the connecting user may read. `report.export`: the completion doorbell for an
          on-demand report export (createReportExport), delivered only to the session of the user
          who requested it; see `ReportExportEvent`. `stream.resync`: sent on reconnect when the
          client's `Last-Event-ID` is older than the server's replay window (see below), telling it
          to fully refetch whatever it was tracking before live streaming resumes; see
          `StreamResyncEvent`. `config.update`: published by a Maintainer's policy or portfolio
          write, delivered org-wide (the payload carries a policy/portfolio id, never a project);
          see `ConfigUpdateEvent`. `sync.update`: published at a sync run's queued/start/complete/
          fail transitions and, throttled, at listing progress, delivered org-wide; see
          `SyncUpdateEvent`. `fix.update`: published on every fix status transition (creation
          included), delivered org-wide; see `FixUpdateEvent`. `config.update`, `sync.update` and
          `fix.update` are notifications, never state carriers: follow up with a plain REST refetch
          (the changed policy/portfolio, `GET /sync/status`, or the affected issue's
          `latest_fix_status`).
        - The `event:` line names which one a given message is; the `data:` payload's shape follows:
          `project.update` conforms to `ProjectUpdateEvent`, `report.export` to `ReportExportEvent`,
          `stream.resync` to `StreamResyncEvent` (always `{}`: the event name is the entire signal),
          `config.update` to `ConfigUpdateEvent`, `sync.update` to `SyncUpdateEvent` (also always
          `{}`), `fix.update` to `FixUpdateEvent`.
        - Every event except `stream.resync` carries an `id:` line shaped `<nonce>-<seq>`. A client
          never parses it; it echoes the whole value back verbatim as `Last-Event-ID` on reconnect.
          It is SSE framing only, never part of the `data:` payload.
        - `Last-Event-ID` is honored on reconnect. The server keeps a bounded, in-memory window of
          the most recent 256 events. When the id was produced by the serving process and is still
          inside the window, every event published after it is replayed in order, through the same
          visibility filter as live events, before live streaming resumes. An id older than the
          window, an id from another server process or an earlier restart, or an unparseable
          header gets `stream.resync` before live streaming resumes. No `Last-Event-ID` header
          (including the very first connection): no replay, no resync. The server sends
          heartbeats.
        - Response body is `text/event-stream`.
        - Delivery beyond the 256-event replay window is best-effort, at-most-once: an event is
          dropped for a client whose buffer is full at that instant, and a client that reconnects
          with an id older than the window, or was not connected when an event fired, cannot
          recover it from this stream; `stream.resync` is the cue that this happened. The stream is
          a freshness signal, never the source of truth: `report.export` in particular names
          `getReportExport` as the authoritative status to poll when unsure.
        - Per-user concurrent stream cap (default 16): a user may hold at most this many streams
          open at once, counted per platform user rather than per session, so two tabs on the same
          account share one budget. The next attempt answers `429` with a JSON body naming the
          cap. Closing one of the user's own streams frees the slot immediately.
      security:
        - sessionCookie: []
      parameters:
        - { name: Last-Event-ID, in: header, required: false, schema: { type: string }, description: 'The `id:` (shaped `<nonce>-<seq>`) of the last event this client processed, echoed back verbatim on reconnect. See the operation description for the 256-event replay/resync behavior.' }
      responses:
        '200':
          description: Event stream.
          content:
            text/event-stream:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/ProjectUpdateEvent'
                  - $ref: '#/components/schemas/ReportExportEvent'
                  - $ref: '#/components/schemas/StreamResyncEvent'
                  - $ref: '#/components/schemas/ConfigUpdateEvent'
                  - $ref: '#/components/schemas/SyncUpdateEvent'
                  - $ref: '#/components/schemas/FixUpdateEvent'
        '401': { description: No live session. }
        '503':
          description: 'The platform could not verify your session (the session or user store is unreachable) or could not resolve your project visibility: an infrastructure failure, distinct from 401. Answered as an ordinary JSON error before the stream opens, never as a half-open stream. During a provider outage the stream may still connect from the role grace window (a role and visibility fact cached before the outage, by default 15 minutes past the cache TTL); a definite provider refusal is never served from it.'
        '403': { description: 'The provider refused your own token (401 or 403 from GitLab): log in again, or your account may have been blocked. The body is the fixed string "your access could not be verified with the provider: log in again, or your account may have been blocked"; distinct from the 503 infrastructure failure. This route has no role threshold of its own (a live session is enough), so this is the only 403 it serves.' }
        '429':
          description: 'The connecting user already has the maximum number of concurrent streams open. The body names the configured cap. Close another stream and retry.'
  /badge/{instance}/{project_path}:
    get:
      operationId: getProjectBadge
      summary: Public score badge (SVG), off by default
      description: |-
        Serves the project's score badge (grade A-E).

        - The path is `{instance}/{project_path}`: a Plumber install serves exactly one org, so
          there is no org segment. The former `{org_slug}/{instance}/{project_path}` form no longer
          resolves.
        - The badge is off by default. It is served only when all three hold: the org toggle
          (`PUT /settings/org` `badge_enabled`), the project toggle (`PUT /projects/{id}/badge`
          below), and the project's observed visibility on the provider is exactly `public`
          (`internal`, `private` and never-observed all refuse). Visibility is refreshed by the
          platform's sync pass, so a project made private on the provider stops serving its badge
          within the next sync cycle, not immediately.
        - The default badge reflects the score-bearing ref (default branch).
        - When the displayed score's run carried a degraded collection, the grade cell reads
          `<letter>*` (e.g. `B*`) instead of the bare letter, and the SVG's accessible text (its
          `title` and `aria-label`) appends " (degraded collection)". Colors and every no-grade
          state are unchanged.
        - A stale or no-result freshness is reflected as such, never a fabricated grade.
        - The 404 body is byte-identical across every refusal (unknown instance, unknown project
          path, org toggle off, project toggle off, non-public visibility, a malformed or
          undecodable path parameter, or an ambiguous match).
      security: []  # public when enabled
      parameters:
        - { name: instance, in: path, required: true, schema: { type: string }, description: 'The git instance name.' }
        - { name: project_path, in: path, required: true, schema: { type: string } }
        - { name: branch, in: query, required: false, schema: { type: string }, description: 'Optional ref; defaults to the score-bearing ref.' }
        - name: style
          in: query
          required: false
          schema: { type: string }
          description: 'Reserved for a future badge style; accepted and ignored today.'
      responses:
        '200':
          description: SVG badge.
          content:
            image/svg+xml: { schema: { type: string } }
        '404': { description: 'No public badge: unknown instance, unknown project path, org or project toggle off, non-public observed visibility, a malformed or undecodable path parameter, or an ambiguous match. The body is byte-identical across every case.' }
        '500': { description: 'An internal failure rendering the badge (e.g. a database error). Never a client-caused rejection.' }

  /projects/{id}/badge:
    put:
      operationId: updateProjectBadge
      summary: Enable/disable a project's own public score badge (OrgAdmin)
      description: |-
        Enables or disables the project's own half of the badge eligibility gate (see
        `GET /badge/{instance}/{project_path}` for the full gate: org toggle, project toggle and
        observed visibility `public`). Requires OrgAdmin.

        - `enabled` is required: an absent field answers 400 naming it; it never silently defaults
          to false or unchanged.
        - `{id}` must be visible to the caller; an invisible or unknown id answers the same 404.
        - The response echoes the badge_enabled value just stored.
        - Turning this on does not by itself make the badge public: the org toggle and the
          project's observed visibility must also hold.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProjectBadgeSettings' }
      responses:
        '200':
          description: The project's badge_enabled state, as stored.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectBadgeSettings' }
        '400': { description: '`enabled` absent (naming it), an unrecognized top-level field, or a malformed project id.' }
        '401': { description: No live session. }
        '403': { description: 'The caller is below OrgAdmin, or the provider refused the caller''s own token (401 or 403 from GitLab): log in again, or your account may have been blocked. In the second case the body is the fixed string "your access could not be verified with the provider: log in again, or your account may have been blocked"; distinct from the 503 infrastructure failure.' }
        '500': { description: 'An internal failure completing the request (e.g. a database error), never a client-caused rejection. The body is the fixed string "sync request failed"; the detail is logged server-side only. Distinct from 503 (a session or role-derivation failure).' }
        '503':
          description: 'The platform could not verify your session, could not determine your role, or could not resolve your visible project set (the session store or the provider was unreachable): an infrastructure failure, distinct from 401 (no live session) and 403 (role below the required threshold). A write is never served from the role grace window and always answers 503 during a provider outage.'
        '404': { description: 'Unknown project id, or a project not visible to the caller (indistinguishable from unknown).' }

  /projects/{id}/official-component:
    put:
      operationId: updateProjectOfficialComponent
      summary: Curate a project as an official component repo (Maintainer)
      description: |-
        Marks or unmarks this project as an "official" component repo. Requires Maintainer. Same single-field toggle shape as `updateProjectBadge`: a required boolean body key, the response echoing the stored value.

        - `official` is required: an absent field answers 400 naming it; it never silently defaults to false or unchanged.
        - The write is not gated on the project's own `is_component_repo` value: `official_component_repo` and `is_component_repo` are independent flags, never derived from one another (see `InventoryProject.official_component_repo`).
        - `{id}` must be visible to the caller: an unknown id, a project belonging to a different org, and an invisible project all answer the same 404.
        - The response echoes the official value just stored.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProjectOfficialComponentSettings' }
      responses:
        '200':
          description: The project's official_component_repo state, as stored.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectOfficialComponentSettings' }
        '400': { description: '`official` absent (naming it), an unrecognized top-level field, or a malformed project id.' }
        '401': { description: No live session. }
        '403': { description: 'The caller is below Maintainer, or the provider refused the caller''s own token (401 or 403 from GitLab): log in again, or your account may have been blocked. In the second case the body is the fixed string "your access could not be verified with the provider: log in again, or your account may have been blocked"; distinct from the 503 infrastructure failure.' }
        '404': { description: 'Unknown project id, a project belonging to a different org, or a project not visible to the caller - all three indistinguishable.' }
        '500': { description: 'An internal failure completing the request (e.g. a database error), never a client-caused rejection. The body is the fixed string "sync request failed"; the detail is logged server-side only. Distinct from 503 (a session or role-derivation failure).' }
        '503':
          description: 'The platform could not verify your session, could not determine your role, or could not resolve your visible project set (the session store or the provider was unreachable): an infrastructure failure, distinct from 401 (no live session) and 403 (role below the required threshold). A write is never served from the role grace window and always answers 503 during a provider outage.'
  /openapi.yaml:
    get:
      operationId: getOpenApiSpec
      summary: Serve this OpenAPI contract itself (Member-gated, raw YAML)
      description: |-
        Returns this OpenAPI contract verbatim as YAML. Requires an authenticated session at Member level.

        - Response headers include `X-Content-Type-Options: nosniff`, so the response is treated as data, never as HTML.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The OpenAPI contract, verbatim.
          content:
            application/yaml:
              schema: { type: string }
        '401': { description: No live session. }
        '403': { description: 'The caller is below Member, or the provider refused the caller''s own token (401 or 403 from GitLab): log in again, or your account may have been blocked. In the second case the body is the fixed string "your access could not be verified with the provider: log in again, or your account may have been blocked"; distinct from the 503 infrastructure failure.' }
        '503':
          description: 'The platform could not verify your session or could not determine your role (the session store or the provider was unreachable): an infrastructure failure, distinct from 401 (no live session) and 403 (role below the required threshold). During a provider outage a GET or HEAD may still be answered from the role grace window (a role cached before the outage, by default 15 minutes past the cache TTL); a write never is.'
  /auth/instances:
    get:
      operationId: listLoginInstances
      summary: List the git instances a browser can log in against
      description: |-
        Unauthenticated: the login page uses it to build its instance picker before any session exists. Only instances an OrgAdmin has finished configuring an OAuth application for are returned, the same predicate /auth/login enforces (a 409 there means the instance would not have appeared here).

        The response never carries a token, authorization code, client secret, session id, PKCE verifier or any other secret-adjacent field: only what a login picker needs to render one clickable option per instance.

        Returns an empty list before any instance has been configured (a fresh install): a valid, ordinary response, not an error.
      security: []
      responses:
        '200':
          description: The login-capable git instances, ordered by instance name.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginInstanceList' }

  /auth/login:
    get:
      operationId: startLogin
      summary: Start the browser login flow against one git instance
      description: |-
        Entry point of the browser login, and the only way to obtain the session cookie every other endpoint below requires.

        This is a browser navigation, not an XHR call: send the user here and let the browser follow the redirect and keep the cookies. Fetching it from application code will not log anyone in.

        - `instance_id` names which configured git instance to authenticate against. That instance must already have an OAuth application configured, which an OrgAdmin does beforehand.
        - Sets a short-lived `plumber_oauth_state` cookie binding this login attempt to this browser. The callback refuses to continue without it, so cookies must survive the round trip to the git instance.
        - A login attempt may sit at the consent screen for 10 minutes. Past that the callback rejects it and the user starts again.
        - No session is required to call this, since this is how a session is obtained.
      security: []
      parameters:
        - { name: instance_id, in: query, required: true, schema: { type: string, format: uuid }, description: 'The git instance to authenticate against.' }
      responses:
        '302':
          description: Redirect to the git instance's OAuth consent screen.
          headers:
            Location:
              schema: { type: string }
              description: The git instance's consent URL.
            Set-Cookie:
              schema: { type: string }
              description: '`plumber_oauth_state`: HttpOnly, SameSite=Lax, Path=/, expiring with the 10 minute attempt window. `Secure` follows the instance''s own TLS configuration, so on a plain-HTTP self-hosted deployment the cookie is set without it.'
        '400': { description: '`instance_id` is absent or is not a UUID.' }
        '404': { description: No such instance. }
        '409': { description: 'That instance has no OAuth application configured, so nobody can log in through it yet.' }
        '429':
          description: >-
            The rate limiter throttled this source IP. The whole login flow (this route plus
            /auth/callback) shares one per-IP budget. `Retry-After` (seconds) says how long until
            the window frees at least one slot.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller's own window frees at least one slot.

  /auth/callback:
    get:
      operationId: completeLogin
      summary: Complete the login and open the session
      description: |-
        The address the git instance redirects the browser back to after consent, and the redirect URI each instance's OAuth application must be registered with. It is called by the browser, never by application code.

        On success it opens the session, sets the `plumber_session` cookie, and redirects to `/`. It returns no body: read who the session belongs to from the endpoints that serve it.

        - Single use. A login attempt completes exactly once, so replaying the same `code` and `state` pair fails, as does a `state` older than 10 minutes.
        - The `plumber_oauth_state` cookie set at login must be present and match `state`. That is checked first, before anything else happens. The cookie is cleared on every outcome, success or failure alike.
        - Beyond what starting a login against the same instance already tells you, no further step is distinguished: a refused code exchange and an unreadable user profile collapse into the same 502, with nothing said about which of the two failed.
      security: []
      parameters:
        - { name: code, in: query, required: true, schema: { type: string }, description: 'Authorization code issued by the git instance.' }
        - { name: state, in: query, required: true, schema: { type: string }, description: 'The state issued at login. Must match the plumber_oauth_state cookie.' }
      responses:
        '302':
          description: Login complete. The session is open.
          headers:
            Location:
              schema: { type: string }
              description: Always `/`.
            Set-Cookie:
              schema: { type: string }
              description: '`plumber_session`: HttpOnly, SameSite=Lax, Path=/, expiring after the configured session lifetime. `Secure` follows the instance''s own TLS configuration, so on a plain-HTTP self-hosted deployment the cookie is set without it.'
        '400': { description: '`state` is malformed, does not match the cookie, or has already been used or has expired. Note that an absent or empty `code` is not rejected here: it reaches the git instance and comes back as the 502 below.' }
        '404': { description: The instance named by the state no longer exists. }
        '409': { description: That instance has no OAuth application configured. }
        '502': { description: 'The git instance refused the code exchange, or its user profile could not be read. No session was opened and nothing was stored.' }
        '429':
          description: >-
            The rate limiter throttled this source IP. This route shares one per-IP budget with
            /auth/login. `Retry-After` (seconds) says how long until the window frees at least one
            slot.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller's own window frees at least one slot.

  /auth/logout:
    post:
      operationId: logout
      summary: End the current session
      description: |-
        Ends the session named by the `plumber_session` cookie and expires that cookie in the browser.

        - Always answers 204, including when there was no live session to end. Logging out is never allowed to fail from the browser's point of view, so the outcome does not depend on whether the cookie was still valid.
      security: []
      responses:
        '204':
          description: The session is ended, or there was none. Either way the cookie is expired.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: '`plumber_session`: same HttpOnly, SameSite=Lax, Path=/ attributes, with a zero lifetime so the browser drops it.'

  /user/invalidate-cache:
    post:
      operationId: invalidateUserCache
      summary: Clear the caller's own cached role/access facts
      description: |-
        Clears every cached role/access fact stored for the calling user, so the next check re-derives them live from the git provider instead of waiting out the cache's TTL.

        Use this right after your access on the git provider changed (added to a group, promoted, removed) and you need the Platform to notice immediately.

        - Requires only an authenticated session (any logged-in user may clear their own cache).
        - Only clears the caller's own cache; there is no endpoint to clear another user's.
      security:
        - sessionCookie: []
      responses:
        '204': { description: The caller's cached role/access facts are cleared. }
        '401': { description: No live session. }
        '500': { description: 'An internal failure completing the request (e.g. a database error), never a client-caused rejection. The body is the fixed string "unable to invalidate cache"; the detail is logged server-side only. Distinct from 503 (a session or role-derivation failure).' }
        '503': { description: 'The platform could not verify your session right now (the session or user store is unreachable): an infrastructure failure, distinct from 401 (no live session).' }
        '429':
          description: >-
            The rate limiter throttled this session. It is keyed per session, never per IP, and
            only checked after the session is verified (an unauthenticated request sees 401). The
            default budget is 20 calls per minute per session. `Retry-After` (seconds) says how
            long until the window frees at least one slot.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller's own window frees at least one slot.

  /me:
    get:
      operationId: me
      summary: The caller's own session identity (any authenticated user)
      description: |-
        Returns the caller's own name, provider username, avatar, and derived role, so a client can render "logged in as" without a separate round trip per field.

        - Requires only an authenticated session; there is no minimum role threshold, as for `/me/role`.
        - `role` is derived live, the same way `/me/role` derives it, never a value stamped at login. When that derivation fails the response is 503, not 403.
        - `username` is nullable: null until this user's next login records it.
        - `id`, `provider`, `instance` and `instance_id` are the caller's platform user id and the (provider, instance name, instance id) of the git instance they are logged into; see CurrentUser's field descriptions, including `instance_id`'s occasionally-null lookup.
        - `org_ui` carries the org-wide UI preferences a client needs at every role (today exactly one: `community_link_hidden`). It is always present, since a Member or Maintainer cannot read `GET /settings/org`.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's own session identity.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CurrentUser' }
        '401': { description: No live session. }
        '500': { description: 'An internal failure completing the request (e.g. a database error), never a client-caused rejection. The body is the fixed string "authorization request failed"; the detail is logged server-side only. Distinct from 503 (a session or role-derivation failure).' }
        '503':
          description: 'The platform could not verify your session or could not determine your role (the session store or the provider was unreachable): an infrastructure failure, distinct from 401 (no live session) and 403 (role below the required threshold). During a provider outage a GET or HEAD may still be answered from the role grace window (a role cached before the outage, by default 15 minutes past the cache TTL); a write never is.'
  /me/role:
    get:
      operationId: myRole
      summary: The caller's own derived role for their instance (any authenticated user)
      description: |-
        Returns the role the platform has just derived for the calling user on their own instance, so a client can decide what to show (e.g. whether to offer the Settings > Authorization grant/revoke actions) without provoking a 403 first.

        - Requires only an authenticated session; there is no minimum role threshold.
        - The role is derived live, the same way every gated route derives it, never a value stamped at login. If that derivation fails (the git instance is unreachable or missing its base URL, the caller's stored access token cannot be used, or the authorization-grant list cannot be read) this endpoint answers 503, never 403. Treat that 503 as "role currently unknown, retry", not as a role of None.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's derived role.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CurrentUserRole' }
        '401': { description: No live session. }
        '500': { description: 'An internal failure completing the request (e.g. a database error), never a client-caused rejection. The body is the fixed string "authorization request failed"; the detail is logged server-side only. Distinct from 503 (a session or role-derivation failure).' }
        '503':
          description: 'The platform could not verify your session or could not determine your role (the session store or the provider was unreachable): an infrastructure failure, distinct from 401 (no live session) and 403 (role below the required threshold). During a provider outage a GET or HEAD may still be answered from the role grace window (a role cached before the outage, by default 15 minutes past the cache TTL); a write never is.'
  /me/provider-token:
    get:
      operationId: myProviderToken
      summary: The caller's own current GitLab OAuth access token, freshly refreshed (Member)
      description: |-
        Returns the calling user's own current GitLab OAuth access token for their instance,
        refreshed the same way the session refresh is, so the token is always current, never a
        stale stored value.

        - Lets a browser client read the provider directly, including private-project template or
          component content it could not otherwise reach.
        - Requires a session at Member level: this route hands back a live provider credential,
          so a session whose role could not be derived is refused with the same `403` every other
          Member-gated read answers.
        - `Cache-Control: no-store` on every response; the token value is never logged.
        - The org token has no equivalent endpoint: this endpoint only ever serves the caller's
          own user credential.
        - A refresh failure (no stored token, no OAuth application configured for the caller's
          instance, a refresh the provider rejects, or a failure persisting the refreshed tokens)
          answers a 5xx error asking the caller to log in again, never a stale or absent token
          served as if it were fresh.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's own current GitLab OAuth access token.
          headers:
            Cache-Control:
              schema: { type: string, enum: [no-store] }
              description: Always `no-store`; this body is a live credential, never cached by any intermediary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProviderToken' }
        '401': { description: No live session. }
        '403': { description: 'The caller is below Member, or the provider refused the caller''s own token (401 or 403 from GitLab): log in again, or your account may have been blocked. In the second case the body is the fixed string "your access could not be verified with the provider: log in again, or your account may have been blocked"; distinct from the 503 infrastructure failure.' }
        '502':
          description: >-
            The caller's provider token could not be refreshed (no stored token, no OAuth
            application configured for the caller's instance, or the provider rejected the
            refresh, e.g. a revoked or already-consumed refresh token). The body is the fixed
            string "unable to refresh your provider token, please re-log-in": log in again.
        '500': { description: 'An internal failure completing the request (e.g. a database error persisting the refreshed token), never a client-caused rejection. The body is the fixed string "authorization request failed"; the detail is logged server-side only. Distinct from 502 (a provider-refresh failure) and 503 (a session-verification failure).' }
        '503': { description: 'The platform could not verify your session right now (the session or user store is unreachable): an infrastructure failure, distinct from 401 (no live session) and 502 (a provider-refresh failure).' }

  /me/tokens:
    get:
      operationId: listMyApiTokens
      summary: The caller's own platform API tokens (any authenticated user)
      description: |-
        Lists the calling user's own platform API tokens, newest first, including revoked and expired
        ones (`revoked_at` / `expires_at` say which). The token value is never returned: only
        `display_prefix` (the first 12 characters, `plmb_` + 7) is readable after creation.
        Session cookie only: a platform API token cannot list or mint tokens.
      security: [ { sessionCookie: [] } ]
      responses:
        '200': { description: The caller's tokens., content: { application/json: { schema: { $ref: '#/components/schemas/ApiTokenList' } } } }
        '401': { description: No live session. }
    post:
      operationId: createMyApiToken
      summary: Mint a platform API token for the caller (any authenticated user)
      description: |-
        Mints a read-only platform API token for the calling user and returns its plaintext once; it is
        never shown again. The token is accepted only by the MCP endpoint (`POST /api/v1/mcp`,
        `Authorization: Bearer plmb_...`), never by a REST route, and carries the caller's own identity:
        every MCP tool applies the same role and visibility rules as the session cookie. At most 10
        active (unrevoked, unexpired) tokens per user; past the cap the call answers 409. `scope` is
        `read` only in this version. The creation is recorded in the configuration audit log as
        `api_token` / `created`, with the token's name as label.
      security: [ { sessionCookie: [] } ]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ApiTokenCreateRequest' } } } }
      responses:
        '201': { description: 'The minted token, plaintext included this one time.', headers: { Cache-Control: { schema: { type: string }, description: 'Always `no-store`.' } }, content: { application/json: { schema: { $ref: '#/components/schemas/ApiTokenCreated' } } } }
        '400':
          description: 'A malformed body, an unknown field, a name absent, empty or over 100 characters, or `expires_at` not in the future; the fixed string names the problem.'
        '401': { description: No live session. }
        '409': { description: The caller already has 10 active tokens; revoke one first. }
  /me/tokens/{id}:
    delete:
      operationId: revokeMyApiToken
      summary: Revoke one of the caller's own platform API tokens (any authenticated user)
      description: |-
        Revokes one of the caller's own tokens by id: the next MCP request carrying it answers 401. An
        unknown id, another user's id and an already-revoked id all answer the same 404. The revocation
        is recorded in the configuration audit log as `api_token` / `deleted`.
      security: [ { sessionCookie: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string, format: uuid } } ]
      responses:
        '204': { description: Revoked. }
        '401': { description: No live session. }
        '404':
          description: 'Unknown id, another user''s id, or an already-revoked id (indistinguishable); the body is the fixed string "token not found".'
  /users:
    get:
      operationId: getUsers
      summary: Batch user identity lookup by id, org-scoped (Member)
      description: |-
        Resolves up to 100 platform user ids to display identity (id, name, username, avatar_url), so a client holding raw owner or actor ids (issue owner, audit actor) can render them in one request. An id that is unknown or belongs to a different org is silently omitted from `items`. Email is never included. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - name: ids
          in: query
          required: true
          style: form
          explode: true
          schema: { type: array, items: { type: string, format: uuid }, minItems: 1, maxItems: 100 }
          description: 'Repeated `ids=<uuid>&ids=<uuid>...`, 1 to 100 entries. An id that is unknown, or belongs to a different org, is silently omitted from items.'
      responses:
        '200':
          description: The resolved identities for every requested id the caller's org actually owns.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserRefs' }
        '400': { description: '`ids` missing, empty, over 100 entries or containing a malformed uuid, or an undocumented query parameter was supplied (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "users request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /version:
    get:
      operationId: getVersion
      summary: The platform's own version and update-check status (any authenticated user)
      description: |-
        Returns this backend's build stamp (`backend_version`, `git_sha`) and the stored result of the last daily update check, so a client can show the running version and, when relevant, an outdated banner. Any authenticated user may read it; there is no minimum role. An unstamped build reports `"dev"`/`"unknown"`. `update_check` is never computed on this request; see `UpdateCheck` for its status vocabulary.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: This backend's own version and update-check status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VersionInfo' }
        '401': { description: No live session. }
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "unable to read update-check status".'
        '503': { description: 'The platform could not verify the session right now (the session store is unreachable).' }

  /instances:
    get:
      operationId: listInstances
      summary: The org's connected git instances (Member)
      description: |-
        Every connected git instance of the caller's org, ordered by (provider, name), with only id, provider, name and base_url: none of the token or OAuth state `GET /settings/instances` carries. `base_url` is null until an OrgAdmin configures it. Unpaginated. Requires Member or above.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's connected git instances.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MemberInstanceList' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "settings update failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /settings/instances:
    get:
      operationId: listSettingsInstances
      summary: Instance settings list, paginated (OrgAdmin)
      description: |-
        Lists every configured git instance of the caller's org, ordered by (provider, instance), with offset pagination (`page`, `per_page`; `total` counts every match across every page). Token and OAuth state appear as metadata and boolean flags only; the token value itself is never returned. Requires OrgAdmin.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the caller's org's instances.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InstanceSettingsList' }
        '400': { description: 'An unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "settings update failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
    post:
      operationId: createInstance
      summary: Create a git instance (OrgAdmin)
      description: |-
        Registers a new git instance for the caller's org (provider, instance name, base_url) with no token or OAuth app configured yet; add those afterwards through the instance settings PUT endpoints. `base_url` must be a parseable absolute http(s) URL. Requires OrgAdmin.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateInstanceRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedID' }
        '400': { description: 'Validation failed: provider/instance/base_url empty, or base_url is not a parseable absolute http(s) URL.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "settings update failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
        '409': { description: 'An instance with the same (org, provider, instance) already exists.' }

  /sync:
    post:
      operationId: triggerSync
      summary: Manually trigger a sync run for one instance (Maintainer)
      description: |-
        Enqueues an asynchronous sync run for `instance_id` in the caller's org; poll `GET /sync/status` or `GET /sync/history` for its progress. `instance_id` must be the instance the caller is logged into: another instance of the caller's own org answers 409 with the remedy, an unknown or foreign instance answers 404. A run already pending or running for the instance also answers 409. Requires Maintainer.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SyncTriggerRequest' }
      responses:
        '202': { description: Sync run enqueued. }
        '400': { description: Malformed request body or instance_id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org - indistinguishable.' }
        '409': { description: 'Either a sync run is already pending or running for this instance, or instance_id belongs to the caller''s own org but is not the session''s own (login) instance - the body names which, and for the latter names the remedy (log in through the requested instance).' }

  /sync/status:
    get:
      operationId: syncStatus
      summary: Latest sync run for the caller's org (Member)
      description: |-
        The most recent sync run across all of the caller's org's instances.

        - `state` is one of `in_progress`, `completed`, or `failed`.
        - Includes counts and dates for the run.
        - When `state` is `failed`, `error` is a short provider-failure summary; it never includes credentials or other secrets.
        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The latest sync run.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncStatus' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
        '404': { description: No sync run has ever been recorded for this org. }

  /sync/history:
    get:
      operationId: syncHistory
      summary: Sync run history for the caller's org, newest first, paginated (Member)
      description: |-
        Every sync run for the caller's org, newest first (`started_at` descending, `id` as tiebreak), with the same per-row shape as `GET /sync/status`; a failed run's `error` is a short summary that never includes credentials. Offset pagination with `page` and `per_page`; `total` counts every run. The former `limit` parameter is no longer accepted and answers 400. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the org's sync run history, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncStatusList' }
        '400': { description: 'An unknown query parameter was supplied, naming it (including the removed `limit`).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects:
    get:
      operationId: listProjects
      summary: Visibility-filtered asset inventory, keyset-paginated (Member)
      description: |-
        The project inventory of the git instance the caller is logged into, filtered to what the caller can see on the provider. By default only live, unarchived projects are listed (the same population as `GET /overview`); `archived=true` and `provider_deleted=true` each opt their own population back in.

        Keyset pagination: `after` is the opaque cursor from a previous response's `next_cursor` (never parse it); `per_page` defaults to 20 and is capped at 100; `total` counts every visible row matching every filter, across every page. A malformed `after` answers 400; a cursor minted under a different `sort`/`order` restarts from the first page of the requested sort instead of erroring.

        Sort: `sort` picks the key (default `path`), `order` the direction (default `asc`), `id` ascending is always the final tiebreak.
        - `path`: by `project_path`.
        - `score_points`: by the displayed score; a project with no scored run sorts last regardless of `order`.
        - `component_ref`: by the pinned component reference as a version: semver refs by semver precedence (`1.2.10` after `1.2.9`), non-semver refs after them (by string), an empty ref last.
        - `last_activity_at`: by the row's own timestamp; a null value sorts last.
        - `worst_branch_score`: by `worst_live_branch_score_points`; a project with no live scorable branch sorts last. It is independent of the displayed score.

        Filters combine: every parameter narrows the page and `total`; a repeated parameter OR-s its values. Each parameter below describes its own matching. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - name: deployment_status
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/DeploymentStatus' } }
          description: 'Optional multi-value filter: repeated `deployment_status=<value>&deployment_status=<value>...`, each value from the DeploymentStatus vocabulary, multiple values OR-ed together. Omitted means no filter.'
        - name: freshness
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/Freshness' } }
          description: 'Optional multi-value filter, the same repeated shape as deployment_status: `freshness=<value>&freshness=<value>...`, each value from the Freshness vocabulary, multiple values OR-ed together. Omitted means no filter.'
        - name: version_check
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/VersionCheck' } }
          description: 'Optional multi-value filter, the same repeated shape as deployment_status: `version_check=<value>&version_check=<value>...`, each value from the VersionCheck vocabulary, multiple values OR-ed together. It narrows on the project''s stored version_check outcome, so `per_page=1&version_check=skipped` gives the fleet count in `total`. A project that pins no component matches no value. Omitted means no filter.'
        - name: coverage
          in: query
          required: false
          schema: { $ref: '#/components/schemas/CoverageState' }
          description: 'Filter rows by derived coverage state.'
        - name: archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: archived rows are excluded (the same population GET /overview uses). true opts them back in, independently of provider_deleted; a row returned this way still carries its own archived/provider_deleted flags. Any value other than "true" or "false" is a 400.'
        - name: provider_deleted
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: provider-deleted rows are excluded (the same population GET /overview uses). true opts them back in, independently of archived; a row returned this way still carries its own archived/provider_deleted flags. Any value other than "true" or "false" is a 400.'
        - name: q
          in: query
          required: false
          schema: { type: string }
          description: 'Case-insensitive literal substring match against the project path or the project''s display name (the `name` field of the response): trimmed, % and _ match literally, a multi-word phrase matches only as an exact substring. A row matching on either is returned and `total` counts the same population. A project with an empty `name` matches on its path only.'
        - name: policy_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: 'Projects this policy governs: direct assignment, reached through a portfolio the policy is attached to (explicit membership or a group: rule), or, when the policy applies to all, every live project of the org within its scope filter whatever its deployment status; archived and provider-deleted projects excluded. Coverage, the stale and no-result counts and licence seats still count onboarded projects only. The same set policy_ids on each row lists.'
        - { name: portfolio_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'Projects in the portfolio''s resolved membership (explicit membership or a group: rule prefix match), the same set portfolio_ids on each row is drawn from.' }
        - { name: language, in: query, required: false, schema: { type: string }, description: 'Matches a key of the row''s languages map, case-insensitively. A row with no languages collected yet never matches.' }
        - { name: last_active_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on last_activity_at (UTC calendar date). A row with no last_activity_at is excluded.' }
        - { name: last_active_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on last_activity_at (UTC calendar date). A row with no last_activity_at is excluded.' }
        - { name: created_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on provider_created_at, the project''s creation date on its git provider (UTC calendar date). A row whose creation date is not known yet is excluded.' }
        - { name: created_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on provider_created_at, the project''s creation date on its git provider (UTC calendar date). A row whose creation date is not known yet is excluded.' }
        - { name: has_onboarding_error, in: query, required: false, schema: { type: boolean }, description: 'Tri-state filter on whether the row carries a stored last_onboarding_error. Omitted means no filter; true keeps only projects that have one; false keeps only projects that do not. Any other value is a 400.' }
        - { name: is_component_repo, in: query, required: false, schema: { type: boolean }, description: 'Optional filter on the row''s is_component_repo flag. Omitted means no filter. Any value other than "true" or "false" is a 400.' }
        - { name: official_component_repo, in: query, required: false, schema: { type: boolean }, description: 'Optional filter on the row''s official_component_repo flag, independent of is_component_repo. Omitted means no filter. Any value other than "true" or "false" is a 400.' }
        - { name: path_prefix, in: query, required: false, schema: { type: string }, description: 'Segment-anchored path filter: matches a project_path equal to, or nested under, this prefix at a full path-segment boundary (bigtech never matches bigtech-500/...).' }
        - { name: direct_children_only, in: query, required: false, schema: { type: boolean }, description: 'Requires path_prefix; narrows to projects exactly one path segment below it. Supplied without path_prefix, answers 400 naming the dependency.' }
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [score_points, component_ref, path, last_activity_at, worst_branch_score], default: path }
          description: 'Sort key for the keyset walk; defaults to path. See the operation description for the per-key semantics.'
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc }, description: 'Sort direction, paired with sort. Defaults to asc.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 clamp down; zero, negative, or malformed values fall back to the default (20).' }
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: 'Opaque cursor from a previous response''s next_cursor; never parse it. Omitted means the first page. A malformed value is a 400; a well-formed value minted under a different sort/order restarts from the first page of the requested sort instead of erroring.'
      responses:
        '200':
          description: One page of the caller's visible slice of the org's asset inventory.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InventoryList' }
        '400': { description: 'A deployment_status, freshness, version_check, coverage, sort or order value outside its vocabulary; archived, provider_deleted, has_onboarding_error, is_component_repo or official_component_repo not "true" or "false"; direct_children_only without path_prefix; a malformed policy_id, portfolio_id, date bound or after cursor (a mismatched sort/order is not an error); or an undocumented query parameter (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/ids:
    get:
      operationId: listProjectIds
      summary: Lean id and path projection of the inventory (Member)
      description: |-
        The lean projection of `listProjects`: `{id, path}` rows only, ordered by path ascending, for draining every project id that matches a filter. Same scope, same default population, same filters with identical meaning and validation, same visibility filtering and the same `total` as `listProjects` for the same filters. Keyset pagination with the same opaque `after` cursor; `per_page` defaults to 100 and is capped at 1000. There is no `sort` or `order` parameter; supplying one answers 400. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - name: deployment_status
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/DeploymentStatus' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - name: freshness
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/Freshness' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - name: version_check
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/VersionCheck' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - { name: coverage, in: query, required: false, schema: { $ref: '#/components/schemas/CoverageState' }, description: 'The same filter listProjects declares.' }
        - { name: archived, in: query, required: false, schema: { type: boolean, default: false }, description: 'The same default-excluded population toggle listProjects declares.' }
        - { name: provider_deleted, in: query, required: false, schema: { type: boolean, default: false }, description: 'The same default-excluded population toggle listProjects declares.' }
        - { name: q, in: query, required: false, schema: { type: string }, description: 'The same substring filter over the project path or the display name listProjects declares, so the two routes always agree on which projects one query string selects.' }
        - { name: policy_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'The same governed-set filter listProjects declares: direct assignment, portfolio-reached, or, for an all policy, every live project of the org within its scope filter whatever its deployment status, archived and provider-deleted projects excluded; coverage, the stale and no-result counts and licence seats still count onboarded projects only.' }
        - { name: portfolio_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'The same resolved-membership filter listProjects declares.' }
        - { name: language, in: query, required: false, schema: { type: string }, description: 'The same languages-key filter listProjects declares.' }
        - { name: last_active_since, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive lower bound listProjects declares.' }
        - { name: last_active_before, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive upper bound listProjects declares.' }
        - { name: created_since, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive provider_created_at lower bound listProjects declares.' }
        - { name: created_before, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive provider_created_at upper bound listProjects declares.' }
        - { name: has_onboarding_error, in: query, required: false, schema: { type: boolean }, description: 'The same tri-state last_onboarding_error filter listProjects declares.' }
        - { name: is_component_repo, in: query, required: false, schema: { type: boolean }, description: 'The same optional filter listProjects declares.' }
        - { name: official_component_repo, in: query, required: false, schema: { type: boolean }, description: 'The same optional filter listProjects declares.' }
        - { name: path_prefix, in: query, required: false, schema: { type: string }, description: 'The same segment-anchored path filter listProjects declares (the parameter a group toggle binds); bigtech never matches bigtech-500/....' }
        - { name: direct_children_only, in: query, required: false, schema: { type: boolean }, description: 'The same narrowing listProjects declares: requires path_prefix (400 naming the dependency when supplied without it), and matches projects exactly one path segment below it.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 100 }, description: 'Capped at 1000; values above 1000 clamp down; zero, negative, or malformed values fall back to the default (100). This raised cap is this operation''s alone: listProjects and listProjectsTree still cap at 100.' }
        - { name: after, in: query, required: false, schema: { type: string }, description: 'Opaque cursor from a previous response''s next_cursor, the same cursor shape listProjects mints, over the (project_path, id) order. A malformed value is a 400; a cursor minted for a different sort restarts from the first page.' }
      responses:
        '200':
          description: One page of the caller's visible slice of the inventory, as identity pairs.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectRefList' }
        '400': { description: 'A deployment_status, freshness, version_check or coverage value outside its vocabulary; archived, provider_deleted, has_onboarding_error, is_component_repo or official_component_repo not "true" or "false"; direct_children_only without path_prefix; a malformed policy_id, portfolio_id, date bound or after cursor; or an undocumented query parameter (named in the body; this includes sort, order and page, which this operation does not accept).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/onboard:
    post:
      operationId: onboardProjects
      summary: Start onboarding a batch of projects, applying a policy set (Maintainer, visible only)
      description: |-
        Starts onboarding for every project in the batch: sets its directly-assigned policies to exactly the submitted `policy_ids` (never a diff), starts a new onboarding attempt and queues the merge request that adds the Plumber integration. On an instance using the external CI configuration method there is no merge request: the project's CI configuration path is written to point at the instance's central CI file, the project moves to in progress and its first Plumber run is started; it becomes onboarded once that run's results arrive from its default branch. Always 200 with per-item results: `{accepted: [{project_id, onboarding_id, warnings}], rejected: [{project_id, reason}]}`; a rejected item is left untouched. Requires Maintainer or above.

        - `project_ids` must be non-empty; `project_ids` and `policy_ids` are each capped at 100 entries (400 otherwise, before any per-item work). An empty `policy_ids` clears every direct assignment; the project still resolves whatever a covering group grants it.
        - Removing a policy this way closes the project's open issues that no longer require any policy; the project is not marked stale.
        - Rejection reasons: `not_found` (unknown, foreign or invisible project, indistinguishable), `not_onboardable` (already onboarded, or a removal in flight; an unboarded project is onboardable again), `no_component` (no integration component configured on the instance), `already_pending` (an attempt is already in flight), `provider_unsupported`, `policy_not_found` (a policy unknown or of another organization), `ci_disabled` (the project's CI/CD is disabled), `ci_config_external` (the project's CI configuration path names an external location other than this instance's central file, or any external location on an instance using the CI file method), `external_ci_config_not_ready` (the instance integrates through an external CI configuration and its central file is not ready: never checked, unreadable, or missing the component).
        - `warnings` on an accepted item are best-effort notes from the project's last synced listing: `no_shared_runners` (shared runners disabled for this project; a specific runner may still be configured).
        - Licence cap: if onboarding the whole batch would push the org past its licence's `project_limit`, the call answers 422 before any per-item work.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OnboardRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OnboardResult' }
        '400': { description: 'Malformed body, empty project_ids, or either project_ids or policy_ids over its own 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '415':
          description: 'Content-Type is not application/json (application/json; charset=utf-8 is accepted, application/json-patch+json is refused). No project in the batch is onboarded.'
        '422': { description: 'Licence project cap exceeded: onboarding this batch would push the org past its licence''s project_limit. The message names the current usage count and the limit (see LicenceStatus/LicenceUsage); no project in the batch is onboarded. A project already counted against the licence (LicenceUsage.project_ids) consumes no additional slot on re-onboard.' }
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "onboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/onboard/merge:
    post:
      operationId: mergeOnboardings
      summary: Batch-merge open onboarding merge requests (Maintainer, visible only)
      description: |-
        Merges the open merge request of every onboarding in the batch using the calling user's own provider OAuth token, so a provider-side rights or mergeability failure is reported per item. Always 200: `{merged: [onboarding_id...], failed: [{id, reason}]}`. `onboarding_ids` must be non-empty and at most 100 entries (400 otherwise). A successful merge completes the onboarding attempt and marks the project `running`; `deployment_status` becomes `onboarded` only once a verified push is received on the project's default branch. Requires Maintainer or above.

        Failure reasons: `not_found` (unknown, foreign or invisible, indistinguishable), `not_mergeable` (no open merge request: never opened, already merged or failed), `merge_not_ready` (the provider has not finished computing mergeability; retry shortly, see `OnboardMergeFailureReason`), `not_approved` / `discussions_not_resolved` / `ci_must_pass` / `need_rebase` / `conflict` (a decided provider blocker), `merge_failed` (any other provider-side refusal, including the caller's OAuth write token being unavailable).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OnboardMergeRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OnboardMergeResult' }
        '400': { description: 'Malformed body, empty onboarding_ids, or onboarding_ids over the 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '415':
          description: 'Content-Type is not application/json (application/json; charset=utf-8 is accepted, application/json-patch+json is refused). No onboarding in the batch is merged.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "onboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/unboard:
    post:
      operationId: unboardProjects
      summary: Start removing Plumber from a batch of onboarded or lost projects (Maintainer, visible only)
      description: |-
        Starts removal of the Plumber integration for every project in the batch: marks the project `unboarding` immediately, starts a new unboarding attempt and queues the merge request that removes the integration from its CI configuration (a project integrated through its CI configuration path instead has that setting reset to the project's own default, and goes straight to `unboarded` once the reset is confirmed); no further analysis push updates its display until the removal merges (`unboarded`) or the attempt fails. Always 200 with per-item results: `{accepted: [{project_id, unboarding_id}], rejected: [{project_id, reason}]}`. `project_ids` must be non-empty and at most 100 entries (400 otherwise). Requires Maintainer or above.

        Rejection reasons: `not_found` (unknown, foreign or invisible, indistinguishable), `not_unboardable` (the project is neither `onboarded` nor `lost`), `already_pending` (an unboarding attempt is already in flight), `in_flight` (an analysis re-check is running for this project; retry once it settles), `provider_unsupported`, `ci_config_path_changed` (the project was integrated through its CI configuration path, and that setting now names another external CI configuration file; it is not reset).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UnboardRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UnboardResult' }
        '400': { description: 'Malformed body, empty project_ids, or project_ids over its own 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "unboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/unboard/merge:
    post:
      operationId: mergeUnboardings
      summary: Batch-merge open removal merge requests (Maintainer, visible only)
      description: |-
        Merges the open removal merge request of every unboarding in the batch using the calling user's own provider OAuth token, so a provider-side failure is reported per item. On a successful merge the project's `deployment_status` becomes `unboarded`. Always 200: `{merged: [unboarding_id...], failed: [{id, reason}]}`. `unboarding_ids` must be non-empty and at most 100 entries (400 otherwise). Requires Maintainer or above.

        Failure reasons: `not_found` (unknown, foreign or invisible, indistinguishable), `not_mergeable` (no open merge request: nothing needed removing, or it already merged or failed), `merge_failed` (the provider rejected the merge: rights, unresolved discussions, conflicts, or the caller's OAuth write token being unavailable).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UnboardMergeRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UnboardMergeResult' }
        '400': { description: 'Malformed body, empty unboarding_ids, or unboarding_ids over the 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "unboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/unboard/cancel:
    post:
      operationId: cancelUnboardings
      summary: Cancel a stuck unboarding, closing its open removal merge request (Maintainer, visible only)
      description: |-
        Cancels every unboarding in the batch that is still `pending` or `mr_opened`: a still-open removal merge request is closed with the calling user's own provider OAuth token (one the provider already reports closed is fine), the unboarding is marked `failed` with a fixed cancellation diagnostic, and the project returns to the state it had before the unboard attempt started. This is the only way out of an `mr_opened` unboarding whose merge request was closed or abandoned on the provider. Always 200: `{cancelled: [unboarding_id...], failed: [{id, reason}]}`. `unboarding_ids` must be non-empty and at most 100 entries (400 otherwise). Requires Maintainer or above.

        Failure reasons: `not_found` (unknown, foreign or invisible, indistinguishable), `not_cancellable` (already `merged` or `failed`, including one resolved a moment earlier), `cancel_failed` (the provider refused to close the merge request: rights, it merged meanwhile, or the caller's OAuth write token being unavailable).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UnboardCancelRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UnboardCancelResult' }
        '400': { description: 'Malformed body, empty unboarding_ids, or unboarding_ids over the 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "unboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/policies:
    get:
      operationId: projectsDirectPolicies
      summary: The directly-assigned policy ids of a set of projects, visible only (Member)
      description: |-
        For every visible project among `project_ids`, the ids of the policies directly assigned to it, as an onboarding policy picker pre-selects them. An unknown, foreign or invisible project id is simply absent from the response, never an error. Requires Member or above.

        When a project's last sync found a Plumber configuration file and the project is still onboardable, its entry also carries an `offer` to import that file's controls as a policy: `match` (an existing policy already has exactly the same controls), `new` (a new policy named "Custom - {project path}" would be created; a preview lists the control types it would enable), or `unavailable` (the offer could not be computed this time, see its reason; the project can still be onboarded normally). A project with no configuration file, or no longer onboardable, carries no `offer` at all.

        `project_ids` is a comma-separated list; at most 100 are considered, any beyond that are ignored. It is the only accepted query parameter: an unrecognized one answers 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - { name: project_ids, in: query, required: true, schema: { type: string }, description: 'Comma-separated project ids.' }
      responses:
        '200':
          description: The directly-assigned policy ids of every visible requested project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectPoliciesList' }
        '400': { description: 'project_ids missing/empty, or an unrecognized query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "onboard request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/tree:
    get:
      operationId: listProjectsTree
      summary: 'One level of the group tree: direct subgroups plus direct child projects (Member)'
      description: |-
        One level of the group tree of the git instance the caller is logged into, in a single request. `parent_path` names the level; omitted or empty means the root. The response has two halves, both scoped and visibility-filtered exactly like `listProjects`. Requires Member or above.

        - `groups`: the direct subgroups of `parent_path`, derived from project paths (a segment with no project under it is not a node). `path` is the group's full path; `name` is the provider's group name when one has been synced, else the last path segment. `project_count` counts every project anywhere below the group that the caller can see and that matches every filter of the request. A subgroup whose every project is invisible to the caller is not listed.
        - `projects`: the direct child projects of `parent_path`, exactly one path segment below it (segment-anchored: `parent_path=bigtech` yields `bigtech/api`, never `bigtech` itself, `bigtech/api/web` or `bigtech-500/anything`). At the root level this is normally empty.

        The full `listProjects` filter family applies to both halves with identical meaning and validation; `path_prefix` and `direct_children_only` are not accepted here. `sort`/`order`, `per_page` and the opaque `after` cursor paginate the `projects` half only (keyset, no `page`); the `groups` half is not paginated (see `groups_truncated`) and is ordered by `groups_sort`. A `parent_path` with nothing under it, or one the caller cannot see into, answers 200 with two empty halves and `total: 0`, never 404.
      security:
        - sessionCookie: []
      parameters:
        - { name: parent_path, in: query, required: false, schema: { type: string }, description: 'The level to read. Omitted or empty means the root level. Segment-anchored: bigtech never matches bigtech-500/.... A value with no children answers empty halves, never a 404.' }
        - { name: groups_sort, in: query, required: false, schema: { type: string, enum: [name, project_count], default: name }, description: 'Orders the groups half: name (default) is display name ascending; project_count is descendant count descending. Both tie-break on path ascending.' }
        - name: deployment_status
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/DeploymentStatus' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - name: freshness
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/Freshness' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - name: version_check
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/VersionCheck' } }
          description: 'The same multi-value filter listProjects declares, with identical meaning and validation.'
        - { name: coverage, in: query, required: false, schema: { $ref: '#/components/schemas/CoverageState' }, description: 'The same filter listProjects declares.' }
        - { name: archived, in: query, required: false, schema: { type: boolean, default: false }, description: 'The same default-excluded population toggle listProjects declares. Narrows both halves.' }
        - { name: provider_deleted, in: query, required: false, schema: { type: boolean, default: false }, description: 'The same default-excluded population toggle listProjects declares. Narrows both halves.' }
        - { name: q, in: query, required: false, schema: { type: string }, description: 'The same substring filter over the project path or the display name listProjects declares. It narrows the counts too: a group''s count reflects only the descendants that match.' }
        - { name: policy_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'The same governed-set filter listProjects declares: direct assignment, portfolio-reached, or, for an all policy, every live project of the org within its scope filter whatever its deployment status, archived and provider-deleted projects excluded; coverage, the stale and no-result counts and licence seats still count onboarded projects only.' }
        - { name: portfolio_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'The same resolved-membership filter listProjects declares.' }
        - { name: language, in: query, required: false, schema: { type: string }, description: 'The same languages-key filter listProjects declares.' }
        - { name: last_active_since, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive lower bound listProjects declares.' }
        - { name: last_active_before, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive upper bound listProjects declares.' }
        - { name: created_since, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive provider_created_at lower bound listProjects declares.' }
        - { name: created_before, in: query, required: false, schema: { type: string, format: date }, description: 'The same inclusive provider_created_at upper bound listProjects declares.' }
        - { name: has_onboarding_error, in: query, required: false, schema: { type: boolean }, description: 'The same tri-state last_onboarding_error filter listProjects declares.' }
        - { name: is_component_repo, in: query, required: false, schema: { type: boolean }, description: 'The same optional filter listProjects declares.' }
        - { name: official_component_repo, in: query, required: false, schema: { type: boolean }, description: 'The same optional filter listProjects declares.' }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [score_points, component_ref, path, last_activity_at, worst_branch_score], default: path }, description: 'Sort key for the projects half only (the same five keys and per-key semantics listProjects documents). The groups half has its own groups_sort.' }
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc }, description: 'Sort direction for the projects half, paired with sort.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Page size for the projects half. Capped at 100; values above 100 clamp down; zero, negative, or malformed values fall back to the default (20).' }
        - { name: after, in: query, required: false, schema: { type: string }, description: 'Opaque cursor for the projects half, from a previous response''s next_cursor, the same cursor listProjects mints, including its restart on a sort/order mismatch. A malformed value is a 400.' }
      responses:
        '200':
          description: One level of the group tree - its direct subgroups and its direct child projects.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectsTree' }
        '400': { description: 'A groups_sort, sort, order, deployment_status, freshness, version_check or coverage value outside its vocabulary; a boolean filter not "true" or "false"; a malformed policy_id, portfolio_id, date bound or after cursor; a parent_path carrying a control character; or an undocumented query parameter (named in the body; this includes path_prefix, direct_children_only and page, which this operation does not accept).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/{id}:
    get:
      operationId: getProject
      summary: Single-project read from the asset inventory (Member, visible only)
      description: |-
        One project's full inventory row, with the same fields, counts and ids a `listProjects` row carries (`policy_ids` is the every-governing-policy set). An unknown id, a project of a different org and a project the caller cannot see all answer the same 404. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The project's full inventory row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InventoryProject' }
        '400': { description: Malformed project id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '404': { description: 'Unknown project id, a project belonging to a different org, or a project not visible to the caller - all three indistinguishable.' }
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/{id}/score-history:
    get:
      operationId: projectScoreHistory
      summary: Daily score trend for one project (Member, visible only)
      description: |-
        A bounded date series in the org calendar, one point per day the project has data for, `items` ordered by `day` ascending (not the standard list envelope). Each point is the project's global score from its last completed run at or before that day's end; a day with no such run, or whose run was unscorable, is absent (a gap, never a zero point, never filled from an older run). `from`/`to` are `YYYY-MM-DD` dates in the org calendar; the default range is the last 90 days ending today, `from` must not be after `to`, and the range is capped at 400 days. Days follow the org's own `timezone` setting (named in the response's `timezone` field); render `day` as a label, never through the browser clock. An unknown or invisible project answers the same 404. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date lower bound (inclusive), in the org calendar. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date upper bound (inclusive), in the org calendar. Defaults to the org today.' }
      responses:
        '200':
          description: The project's daily score trend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistory' }
        '400': { description: 'Malformed project id, a malformed from/to date, from after to, or a resolved range over 400 days.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed", or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
        '404': { description: 'Unknown project id, or a project not visible to the caller (indistinguishable from unknown).' }

  /projects/{id}/branches:
    get:
      operationId: listProjectBranches
      summary: Per-branch live-score survey for one project (Member, visible only)
      description: |-
        The project's live branches, derived from run history: every branch whose newest run was received
        within the last 30 days (a fixed window), with that run's own score (or `scorable: false`, in
        which case `score_letter`/`score_points` carry no meaning) and gate outcome. A branch whose newest
        run ages past the window drops out on the next read. This survey is independent of which branch
        owns the project's displayed score (`score_source`) and never feeds the displayed score, the
        rollups, the gate or the badge. Ordered worst score first, unscorable branches last, branch name
        ascending as the tiebreak; at most the worst 200 branches are reachable across every page, while
        `total` counts every live branch. Offset pagination with `page` and `per_page`. An unknown,
        foreign or invisible project answers the same 404. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: 'One page of this project''s live branches, worst-score-first.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectBranchList' }
        '400': { description: 'Malformed project id (not a valid uuid), or an undocumented query parameter was supplied (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.'
        '404': { description: 'Unknown project id, a project belonging to a different org, or a project not visible to the caller - all three indistinguishable.' }
        '500':
          description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set (session store or provider unreachable). During a provider outage a GET or HEAD may still be served from a role cached before the outage (a bounded grace window, 15 minutes past the cache TTL by default); a write always answers 503.'
  /projects/{id}/score-context:
    get:
      operationId: getProjectScoreContext
      summary: The contextual score behind a project's score (Member, visible only)
      description: |-
        The policy results of one run with their score and, when the CLI sent one, their score context: the attack paths, the situation paragraph and the best fix (contextual score, `scoring-v4`). Without `run_id` the run is the one owning the project's displayed score; a project with no scored run answers `items: []` without `run_id`. A result scored with `scoring-v3`, a withheld result and a result pushed by a CLI older than v0.6.0 carry no `context`. Sentences, the situation, job names and the best-fix subject are plain text: render them as text. An unknown project, a project of another org and a project the caller cannot see all answer the same 404, as does a `run_id` of another project. Requires Member or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: policy_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'Only this policy''s result.' }
        - { name: run_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'A run of this project; defaults to the run owning the displayed score.' }
      responses:
        '200':
          description: The run's policy results with their score context.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectScoreContext' }
        '400': { description: 'Malformed project id, policy_id or run_id, or an undocumented query parameter.' }
        '401': { description: No live session. }
        '403': { description: 'The caller is below Member, or the provider refused the caller''s token; log in again, or the account may have been blocked.' }
        '404': { description: 'Unknown project id, a project of another org, a project not visible to the caller, or a run_id of another project.' }
        '500': { description: 'An internal failure, not caused by the request; the body is the fixed string "sync request failed".' }
        '503': { description: 'The platform could not verify the session, determine the caller''s role, or resolve the caller''s visible project set.' }

  /projects/{id}/dependencies:
    get:
      operationId: getProjectDependencies
      summary: One project's bill of materials, grouped by kind (Member, visible only)
      description: |-
        Returns what one project's pipeline depends on, grouped by resource kind, each entry carrying how many other visible projects use the same resource. Requires Member; the project must be visible to the caller.

        - The bill comes from the project's latest default-branch run that pushed one, plus the CI/CD variables of its latest project snapshot; runs on other branches never contribute. A push without a bill of materials leaves the previous one in place, so `run_id` names the run the bill actually came from. An unboarded project answers empty arrays until its next default-branch push or snapshot refresh after re-onboarding.
        - `by_kind` always carries every DependencyKind as a key: a project with no bill and no variables answers six empty arrays with `run_id` and `last_seen` both null. `run_id` describes the pushed half alone, `last_seen` whichever half refreshed most recently.
        - Every `key`, `version` and `attrs` member is the CLI's own value, served unchanged. A variable entry carries the metadata read from the provider (masked, protected, hidden, environment, type, origin); the value is never stored or served.
        - `consumer_count` counts visible projects only. An unknown id, a project of a different org and an invisible project all answer the same 404.
        - Not paginated: the bill is bounded at ingest and served whole.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The project's bill of materials, grouped by resource kind.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDependencies' }
        '400': { description: 'Malformed project id (not a valid uuid), or an undocumented query parameter was supplied (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '404': { description: 'Unknown project id, a project belonging to a different org, or a project not visible to the caller - all three indistinguishable.' }
        '500':
          description: 'An internal error; the body is the fixed string "dependencies request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /projects/{project_path}/recheck:
    post:
      operationId: recheckProject
      summary: Manually re-trigger analysis for one project (Member, visible only)
      description: |-
        Retries the project's last Plumber CI job on the provider. The CLI re-fetches the current policy at runtime, so the re-check verifies against the current configuration, whatever the project's current freshness. Requires Member; the project must be visible to the caller.

        - This call only enqueues (202); the state change (`running`, then `fresh` or `no result received`) arrives over the SSE stream.
        - A project already `running`, archived, or deleted on the provider answers 409.
        - A project with no prior Plumber run still answers 202; the outcome (`no_prior_run`, with the window searched) is then readable on `last_trigger` in `GET /projects/{id}`, not on the SSE stream.
        - An unknown or invisible path answers the same 404.
      security:
        - sessionCookie: []
      parameters:
        - { name: project_path, in: path, required: true, schema: { type: string }, description: 'Provider project path.' }
      responses:
        '202': { description: Re-check enqueued; the state change arrives over SSE. }
        '400':
          description: 'Malformed id or path parameter.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "recheck request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '404': { description: 'Unknown project path, or a project not visible to the caller (indistinguishable from unknown).' }
        '409':
          description: 'A run is already pending or running for this project, or the project is archived or deleted on the provider.'
  /config/policies/{id}/recheck:
    post:
      operationId: recheckPolicy
      summary: Manually re-trigger analysis for a policy's stale-family projects (Member, visible only)
      description: |-
        Enqueues a re-check for every project assigned to the policy (directly or through a portfolio) that the caller can see and that is currently `stale` or `no result received`. Visible members already `running` or `fresh` are skipped. Requires Member.

        - `triggered` and `skipped` count the caller's visible projects only.
        - Zero stale visible members is not an error: `202 {"triggered": 0, "skipped": n}`.
        - A project whose enqueue fails is skipped; `triggered` reflects only the successes.
        - An unknown policy id, or one of a different org, answers 404.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '202':
          description: Re-check enqueued for the visible stale-family members.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecheckResult' }
        '400':
          description: 'Malformed id or path parameter.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "recheck request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }

  /config/portfolios/{id}/recheck:
    post:
      operationId: recheckPortfolio
      summary: Manually re-trigger analysis for a portfolio's stale-family projects (Member, visible only)
      description: |-
        Same as recheckPolicy, over the portfolio's resolved project set (explicit projects plus group rule matches). Requires Member.

        - `triggered` and `skipped` count the caller's visible projects only.
        - An unknown portfolio id, or one of a different org, answers 404.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '202':
          description: Re-check enqueued for the visible stale-family members.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecheckResult' }
        '400':
          description: 'Malformed id or path parameter.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "recheck request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }

  /recheck:
    post:
      operationId: recheckOrg
      summary: Manually re-trigger analysis for the caller's whole visible instance pool (org-wide, Maintainer)
      description: |-
        Same as recheckPolicy, over every project of the caller's instance pool. Requires Maintainer.

        - `triggered` and `skipped` count the caller's visible projects only.
      security:
        - sessionCookie: []
      responses:
        '202':
          description: Re-check enqueued for the visible stale-family members.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecheckResult' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "recheck request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /overview:
    get:
      operationId: getOverview
      summary: Coverage rollup over the caller's visible projects (Member)
      description: |-
        The coverage rollup (adoption counts) over the caller's visible projects. Requires Member.

        - Counts cover live, unarchived projects only: archived and provider-deleted projects are always excluded (unlike `GET /projects`, whose `archived`/`provider_deleted` parameters restore them there).
        - Counts are scoped to the caller's own org, provider and instance (the git instance they are logged into), never narrowed to one portfolio or policy. `GET /sync/status` is scoped to the org alone.
        - `score_points`/`score_letter` are the org-wide score average, a different scope: not narrowed to the login instance and not visibility-filtered (see `Overview.score_points`).
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The rollup.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Overview' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "could not load the overview".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /overview/score-history:
    get:
      operationId: overviewScoreHistory
      summary: Daily org-wide score trend (Member)
      description: |-
        A bounded date series in the org calendar, one point per day the org has data for; not the standard list envelope. Requires Member.

        - `from`/`to` are `YYYY-MM-DD` calendar dates in the org calendar, both optional: `to` defaults to today, `from` to `to` minus 89 days. The range must not exceed 400 days and `from` must not be after `to` (400 otherwise).
        - Each `day` is the average, rounded to one decimal, of the latest run points as of that day's end across every project of the org that has a scorable run: every project and every instance, not narrowed to the caller's instance and not visibility-filtered (the history of `Overview.score_points`). A day on which no project had a scorable run is absent from `items`, never a zero.
        - Days before today come from the stored daily rollup; if the range includes today, a live point for today is appended, and omitted if it cannot be computed.
        - `items` is ordered by `day` ascending, with no backfill and no interpolation: a gap stays a gap.
        - Days follow the org's own `timezone` setting, the same for every viewer; render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
      security:
        - sessionCookie: []
      parameters:
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound, a calendar date in the org calendar. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound, a calendar date in the org calendar. Defaults to the org''s today.' }
      responses:
        '200':
          description: The org's daily score trend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistory' }
        '400': { description: 'A malformed from/to date, from after to, or a resolved range over 400 days.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "could not load the overview", or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /overview/top-projects:
    get:
      operationId: topProjects
      summary: The caller's top-scoring visible projects (Member)
      description: |-
        The caller's visible projects that have a run, ordered by `score_points` descending then `project_path` ascending, as slim rows. Requires Member.

        - A project that has never had a run is excluded.
        - `limit` (default 5, maximum 20) bounds the row count.
        - `order=asc` reverses only the score direction (worst-scoring first); the `project_path` tiebreak stays ascending. Any other value answers 400.
        - An undocumented query parameter answers 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 5, maximum: 20 }, description: 'Default 5, maximum 20; values above 20 are rejected, not clamped.' }
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: 'desc (default): score_points descending, project_path ascending as the tiebreak. asc reverses only the score direction (worst first); the tiebreak is unchanged. Any other value answers 400 naming the allowed set.'
      responses:
        '200':
          description: The caller's top-scoring visible projects.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TopProjects' }
        '400': { description: 'An undocumented query parameter was supplied (naming it), limit is not a positive integer at or under 20, or order is not "asc" or "desc".' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "could not load the overview".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues:
    get:
      operationId: listIssues
      summary: The issues list, over the caller's visible projects (Member)
      description: |-
        One page of issues across the caller's visible projects. Every filter is optional; different filters AND together. Without a `status` filter, Dismissed issues are excluded; `status=Dismissed` shows them. Requires Member.

        - `status`, `severity`, `control_type`, `issue_type` and `owner` are multi-value: repeat the parameter to OR values within one filter, at most 100 values each (a 101st answers 400). There is no single-value alias.
        - `sort` (`first_detected_at` | `updated_at` | `severity`, default `first_detected_at`) picks the ordering key and `order` (`asc` | `desc`, default `desc`) its direction. `severity` orders semantically: `critical` > `high` > `medium` > `low` > unset. Any other value answers 400.
        - `project` is an exact provider project path; `portfolio` resolves to the portfolio's current project set; `policy` is a policy id or name and matches issues currently required by that policy. An unknown, foreign-org or invisible value answers an empty page, never an error.
        - `q` is a case-insensitive literal substring match against `control_type`, the issue code (ISSUE-nnn) and the project path; `%` and `_` match literally.
        - `fixable=true` narrows to issues currently eligible for `triggerFix` (a fix exists for the issue's control and its status is `Detected` or `In progress`); only `true`/`false` are accepted. `total` reflects this filter too.
        - `severity` values are matched case-insensitively against `critical`/`high`/`medium`/`low`; an issue with no severity never matches.
        - `first_detected_since`/`first_detected_before` bound `first_detected_at` and `updated_since`/`updated_before` bound `updated_at`: UTC calendar dates, inclusive at both ends, each optional, combining with every other filter and with `sort`/`order`. An invalid date answers 400.
        - By default the issues of projects deleted on the provider are excluded from `items` and `total`; `provider_deleted=true` includes them.
        - Any undocumented query parameter answers 400 naming it.
        - `page` (default 1, minimum 1, clamped up) and `per_page` (default 20, capped at 100) paginate after visibility and filters; `total` is that count.
      security:
        - sessionCookie: []
      parameters:
        - name: status
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/IssueStatus' }, maxItems: 100 }
          description: 'Multi-value: repeat `status=<value>` to OR exact values, at most 100. Omitted means every status except Dismissed.'
        - name: control_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'Multi-value: repeat `control_type=<value>` to OR exact values, at most 100. Omitted means no filter.'
        - name: issue_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, pattern: '^ISSUE-[0-9]{3}$' }, maxItems: 100 }
          description: 'Multi-value: repeat `issue_type=<value>` to OR exact values, at most 100. The issue''s own ISSUE-nnn code (IssueSummary.code, the key of GET /issue-types): what fired, whereas control_type filters the owning control, which may own several issue types. Every value must match ^ISSUE-[0-9]{3}$; any other value answers 400 naming the field. Omitted means no filter.'
        - name: severity
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [critical, high, medium, low] }, maxItems: 100 }
          description: 'Multi-value: repeat `severity=<value>` to OR values, at most 100. Values are matched case-insensitively against critical/high/medium/low; any other value answers 400 naming the field. An issue with no severity never matches. Omitted means no filter.'
        - name: base_severity
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [critical, high, medium, low] }, maxItems: 100 }
          description: 'Multi-value, matched case-insensitively against critical/high/medium/low like `severity`, on the base severity.'
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path.' }
        - { name: portfolio, in: query, required: false, schema: { type: string, format: uuid }, description: 'Portfolio id; resolved to its current project set.' }
        - name: policy
          in: query
          required: false
          schema: { type: string }
          description: 'A policy id (uuid) or a policy name; matches issues currently required by that policy. An unknown value answers an empty page.'
        - name: owner
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'Multi-value: repeat `owner=<value>` to OR exact platform user ids, at most 100. Omitted means no filter.'
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Case-insensitive literal substring match against control_type, the issue code (ISSUE-nnn, so q=505 finds ISSUE-505) and the project path; trimmed, % and _ match literally, multi-word phrases match only as exact substrings.' }
        - name: fixable
          in: query
          required: false
          schema: { type: boolean }
          description: 'true narrows to issues currently eligible for triggerFix (a fix exists for the issue''s control and its status is Detected or In progress). Only true/false are accepted; any other value answers 400.'
        - { name: provider_deleted, in: query, required: false, schema: { type: boolean, default: false }, description: 'Omitted or false hides the issues of projects deleted on the provider (their status is untouched); true includes them, as on listProjects.' }
        - { name: first_detected_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on the issue''s first_detected_at (UTC calendar date). Omitted means no bound.' }
        - { name: first_detected_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on the issue''s first_detected_at (UTC calendar date); the named day is in range. Omitted means no bound.' }
        - { name: updated_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on the issue''s updated_at, when it last moved (UTC calendar date). Independent of the first_detected bounds. Omitted means no bound.' }
        - { name: updated_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on the issue''s updated_at (UTC calendar date); the named day is in range. Omitted means no bound.' }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [first_detected_at, updated_at, severity], default: first_detected_at }
          description: 'Ordering key, default first_detected_at. severity orders semantically (critical > high > medium > low > empty), never alphabetically. Any other value answers 400.'
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: 'Ordering direction, default desc. Any other value answers 400.'
      responses:
        '200':
          description: One page of the caller's visible issues.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueList' }
        '400': { description: 'An undocumented query parameter (named in the body); a status, severity, fixable, sort or order value outside its vocabulary (naming the field and the allowed set); provider_deleted not "true" or "false"; a period bound that is not a valid YYYY-MM-DD date (naming the field); an issue_type value outside ^ISSUE-[0-9]{3}$ (naming the field); or a multi-value filter repeated more than 100 times (naming the field).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/summary:
    get:
      operationId: getIssuesSummary
      summary: Issue counts by status and severity, over the caller's visible projects (Member)
      description: |-
        The same population `listIssues` answers, under the same filters and the same visibility, as counts instead of a page: `total`, `by_status` and `by_severity`. Accepts every `listIssues` query parameter except `page`, `per_page`, `sort` and `order`, with the same semantics; any other parameter answers 400 naming it. Requires Member.

        - `total` and `by_severity` apply the caller's filters exactly as `listIssues` does, including the default status rule (every status except Dismissed) and the default `provider_deleted` rule; `total` equals `listIssues`' `total` for the same query string.
        - `by_status` ignores the `status` filter and keeps every other one, so selecting a status does not zero the other status counts.
        - `by_status` always carries all four statuses (`Detected`, `In progress`, `Fixed`, `Dismissed`) and `by_severity` all four severities (`critical`, `high`, `medium`, `low`), with `0` where nothing matches.
        - An issue with no severity counts in `total` and its `by_status` bucket but in no `by_severity` bucket, so the `by_severity` sum can be smaller than `total`.
        - An unknown or foreign-org `portfolio`/`policy`, or an unknown or invisible `project`, answers all zeros with every key present, never an error.
      security:
        - sessionCookie: []
      parameters:
        - name: status
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/IssueStatus' }, maxItems: 100 }
          description: 'Multi-value, as on listIssues. Narrows `total` and `by_severity` but not `by_status` (see the operation description). Omitted means every status except Dismissed, for `total`/`by_severity`.'
        - name: control_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'MULTI-VALUE, as on listIssues. Narrows all three numbers.'
        - name: issue_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, pattern: '^ISSUE-[0-9]{3}$' }, maxItems: 100 }
          description: 'Multi-value: repeat `issue_type=<value>` to OR exact values, at most 100. The issue''s own ISSUE-nnn code (IssueSummary.code, the key of GET /issue-types): what fired, whereas control_type filters the owning control, which may own several issue types. Every value must match ^ISSUE-[0-9]{3}$; any other value answers 400 naming the field. Omitted means no filter. Narrows all three numbers.'
        - name: severity
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [critical, high, medium, low] }, maxItems: 100 }
          description: 'MULTI-VALUE and case-insensitive against the frozen vocabulary, as on listIssues. Narrows all three numbers, `by_status` included.'
        - name: base_severity
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [critical, high, medium, low] }, maxItems: 100 }
          description: 'Multi-value, matched case-insensitively against critical/high/medium/low like `severity`, on the base severity.'
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path.' }
        - { name: portfolio, in: query, required: false, schema: { type: string, format: uuid }, description: 'Portfolio id; resolved to its current project set.' }
        - { name: policy, in: query, required: false, schema: { type: string }, description: 'A policy id (uuid) or a policy name, matched as on listIssues.' }
        - name: owner
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'MULTI-VALUE, as on listIssues.'
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Substring match against control_type, the issue code and the project path, case-insensitive, literal - exactly as on listIssues.' }
        - { name: fixable, in: query, required: false, schema: { type: boolean }, description: 'true narrows to issues currently eligible for triggerFix, exactly as on listIssues. Strict: only true/false are accepted.' }
        - { name: provider_deleted, in: query, required: false, schema: { type: boolean, default: false }, description: 'Omitted or false hides the issues of projects deleted on the provider; true includes them. Applies to all three numbers.' }
        - { name: first_detected_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on first_detected_at (UTC calendar date). Omitted means no bound.' }
        - { name: first_detected_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on first_detected_at (UTC calendar date); the named day is in range. Omitted means no bound.' }
        - { name: updated_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on updated_at (UTC calendar date). Omitted means no bound.' }
        - { name: updated_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on updated_at (UTC calendar date); the named day is in range. Omitted means no bound.' }
      responses:
        '200':
          description: The counts for the caller's visible, filtered issues.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssuesSummary' }
        '400': { description: 'An undocumented query parameter (named in the body; page, per_page, sort and order are not accepted here); a status, severity or fixable value outside its vocabulary; provider_deleted not "true" or "false"; a period bound that is not a valid YYYY-MM-DD date (naming the field); an issue_type value outside ^ISSUE-[0-9]{3}$ (naming the field); or a multi-value filter repeated more than 100 times (naming the field).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/trend:
    get:
      operationId: issuesTrend
      summary: Daily open-issue count trend over the caller's visible issues (Member)
      description: |-
        For each day in the range, the count of matching issues that were `Detected` or `In progress` at the end of that day in the org calendar, reconstructed exactly from the issue history. Requires Member.

        - Accepts the same filters as `listIssues` except `status` (the series itself is the status dimension): `control_type`, `issue_type`, `severity`, `project`, `portfolio`, `policy`, `owner`, `q`, `fixable` and `provider_deleted`, with the same semantics.
        - Filters evaluate against each issue's current attributes, not its attributes on that day: `owner` and `severity` narrow every day of the series to issues currently owned by that user or currently at that severity.
        - `from`/`to` are `YYYY-MM-DD` calendar dates in the org calendar, both optional: `to` defaults to today, `from` to `to` minus 89 days. The range must not exceed 400 days and `from` must not be after `to` (400 otherwise).
        - `items` has one entry per day in the range, ascending; a day with zero open issues still gets an entry with `open_count` 0.
        - How the series is computed is not part of the contract and may change; do not assume either shape.
        - Any undocumented query parameter answers 400 naming it.
        - Days follow the org's own `timezone` setting, the same for every viewer; render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
      security:
        - sessionCookie: []
      parameters:
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound, a calendar date in the org calendar. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound, a calendar date in the org calendar. Defaults to the org''s today.' }
        - name: control_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'Multi-value: repeat `control_type=<value>` to OR exact values matched against each issue''s current control_type, at most 100. Omitted means no filter.'
        - name: issue_type
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, pattern: '^ISSUE-[0-9]{3}$' }, maxItems: 100 }
          description: 'Multi-value: repeat `issue_type=<value>` to OR exact values, at most 100. The issue''s own ISSUE-nnn code (IssueSummary.code, the key of GET /issue-types): what fired, whereas control_type filters the owning control, which may own several issue types. Every value must match ^ISSUE-[0-9]{3}$; any other value answers 400 naming the field. Omitted means no filter. Narrows the candidate set of every daily point.'
        - name: severity
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string, enum: [critical, high, medium, low] }, maxItems: 100 }
          description: 'Multi-value: repeat `severity=<value>` to OR values, at most 100. Values are matched case-insensitively against critical/high/medium/low, against each issue''s current severity; any other value answers 400. An issue with no severity never matches. Omitted means no filter.'
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path.' }
        - { name: portfolio, in: query, required: false, schema: { type: string, format: uuid }, description: 'Portfolio id; resolved to its current project set.' }
        - name: policy
          in: query
          required: false
          schema: { type: string }
          description: 'A policy id (uuid) or a policy name, matched as on listIssues.'
        - name: owner
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { type: string }, maxItems: 100 }
          description: 'Multi-value: repeat `owner=<value>` to OR exact platform user ids, matched against each issue''s current owner across every day of the series, at most 100. Omitted means no filter.'
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Case-insensitive literal substring match against control_type, the issue code (ISSUE-nnn, so q=505 finds ISSUE-505) and the project path; trimmed, % and _ match literally, multi-word phrases match only as exact substrings.' }
        - name: fixable
          in: query
          required: false
          schema: { type: boolean }
          description: 'true narrows to issues currently eligible for triggerFix (a fix exists for the issue''s control and its status is Detected or In progress), evaluated against each issue''s current state. Only true/false are accepted; any other value answers 400.'
        - { name: provider_deleted, in: query, required: false, schema: { type: boolean, default: false }, description: 'Omitted or false hides the issues of projects deleted on the provider from every daily point, the same population the list and the summary show; true includes them, as on listIssues.' }
      responses:
        '200':
          description: The caller's daily open-issue count trend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssuesTrend' }
        '400': { description: 'An undocumented query parameter (named in the body); a severity or fixable value outside its vocabulary (naming the field and the allowed set); provider_deleted not "true" or "false"; an issue_type value outside ^ISSUE-[0-9]{3}$ (naming the field); a multi-value filter repeated more than 100 times (naming the field); a malformed from/to date; from after to; or a range over 400 days.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed", or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/{id}:
    get:
      operationId: getIssueDetail
      summary: One issue's full detail, including its status-move history (Member, visible only)
      description: |-
        One issue's full detail, including its status history. The issue must belong to the caller's org and to a project the caller can see; otherwise 404. Requires Member.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The issue's full detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueDetail' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '404': { description: 'Unknown id, an issue of a different org, or an issue whose project is not visible to the caller; indistinguishable.' }

  /issues/status:
    post:
      operationId: setIssuesStatus
      summary: Batch manual status move (Maintainer)
      description: |-
        Applies one manual status move to every issue in the batch, with per-issue results: `{updated: [id...], failed: [{id, reason}...]}`, always 200. Requires Maintainer.

        - Allowed moves: `Detected` <-> `In progress`, `Detected` or `In progress` -> `Dismissed`, `Dismissed` -> `Detected`. `Fixed` cannot be set manually (it is automatic, on the next push); naming it fails that issue with `forbidden_transition`.
        - Per-issue failure reasons: `not_found` (an unknown, foreign-org or invisible-project id), `forbidden_transition`, `already_in_status`.
        - Each applied move is recorded in the issue's history with the caller as actor and the optional `comment`.
        - `issue_ids` must be non-empty and at most 500 entries, `to_status` one of the status vocabulary, and `comment` within the length bound; otherwise 400 and nothing is written.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueStatusRequest' }
      responses:
        '200':
          description: Per-issue batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueBatchResult' }
        '400': { description: 'Empty issue_ids, issue_ids over the 500-item cap, an unrecognized to_status, or a comment over the length bound.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/{id}/comment:
    post:
      operationId: addIssueComment
      summary: Comment-only triage entry, no status change (Member, visible only)
      description: |-
        Adds a comment to the issue's history without changing its status, with the caller as actor. The issue must belong to the caller's org and to a project the caller can see (404 otherwise). Requires Member.

        - `comment` is required: absent, empty or whitespace-only answers 400, as does a comment over the length bound. An unrecognized top-level field answers 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueCommentRequest' }
      responses:
        '204': { description: Comment recorded. }
        '400': { description: 'comment genuinely absent, an unrecognized top-level field (named), an empty or whitespace-only comment, or a comment over the length bound.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '404': { description: 'Unknown id, an issue of a different org, or an issue whose project is not visible to the caller; indistinguishable.' }

  /issues/owner:
    post:
      operationId: setIssuesOwner
      summary: Batch manual owner assignment (Maintainer)
      description: |-
        Sets or clears `owner` for every issue in the batch, with the same per-issue result shape as setIssuesStatus: `{updated: [id...], failed: [{id, reason}...]}`, always 200. Requires Maintainer.

        - `owner` is the platform user id of an existing user of the caller's org, or the empty string to unassign; anything else answers 422 for the whole request.
        - An assignment is not a status move and writes no history entry.
        - Per-issue failure reason: `not_found` only (an unknown, foreign-org or invisible-project id).
        - `issue_ids` must be non-empty and at most 500 entries (400 otherwise, nothing written).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueOwnerRequest' }
      responses:
        '200':
          description: Per-issue batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueBatchResult' }
        '400': { description: 'Empty issue_ids, or issue_ids over the 500-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "issues request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
        '422': { description: 'owner is neither empty nor the id of an existing platform user of the caller''s org: a malformed value, or an unknown or foreign-org user id.' }

  /issues/fix:
    post:
      operationId: triggerFix
      summary: Batch fix trigger, one fix per accepted item (Member, visible only)
      description: |-
        Triggers a fix for every item in the batch, with per-item results, always 200: `{accepted: [{issue_id, fix_id}], rejected: [{issue_id, reason}]}`. Requires Member; the acting user's own git rights are what the provider enforces when the fix runs, and a provider refusal surfaces as the fix's own failure.

        - `items` must be non-empty and at most 100 entries (400 otherwise, nothing accepted).
        - `inputs`, when present on an item, is capped at 1 KB per string value and 4 KB per item; an oversized item answers 400 for the whole request. Accepted inputs are stored verbatim on the fix.
        - Two `inputs` keys are interpreted (see `FixItemRequest.inputs`): `group` (the chosen alternative, a 0-based index as a string) and `components` (per-component input values, keyed by the component path `getFixInputs` serves). A malformed `group` or `components` answers 400 for the whole request; every other key is stored uninterpreted.
        - A required component input with no value makes the fix itself fail (`required input <name> has no value for <path>` in the fix's `error`), opening no branch and no merge request; call `getFixInputs` first to know which values to collect.
        - Rejection reasons: `not_found` (an unknown, foreign-org or invisible-project issue), `not_fixable` (no fix exists for the issue's control, the issue is not `Detected`/`In progress`, or the fix cannot apply on this git instance, in which case `detail` says why), `duplicate_pending` (a fix for this issue is still `pending` or `mr_opened`; once it is `done`, `failed` or `merged`, a new trigger is accepted).
        - A missing or unusable write token is never a rejection: it surfaces as the fix's own `failed` status (see `listFixHistory`).
        - Every accepted item creates one `pending` fix owned by the caller and enqueues it; if the enqueue fails the fix stays `pending` and is picked up later.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FixTriggerRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixTriggerResult' }
        '400':
          description: 'Malformed body, empty items, items over the 100-item cap, an item''s inputs over a bound (body "inputs exceed the allowed bounds": 4 KB per item, 1 KB per string value at any depth, a list over 64 entries, or a component path over 512 bytes), a non-string `inputs.group` (body "inputs.group must be a string"), an `inputs.components` that is not an object of objects of scalars or lists of scalars, or whose input names are not plain YAML keys of at most 128 bytes, or whose values are not printable text (body "inputs.components must be an object of objects"), or a requiring policy whose `required` expression is past the supported bounds (that bound''s own message). Every check runs for every item before any is accepted: the whole request fails, never a partial accept.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "fix request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/fix/merge:
    post:
      operationId: mergeFixes
      summary: Batch-merge open merge-request fixes with the caller's own token (Member, visible only)
      description: |-
        Merges the open merge request of every fix in the batch with the calling user's own provider token, so their git rights are what the provider enforces; a provider refusal surfaces as the item's own failure. Per-item results, always 200: `{merged: [fix_id...], failed: [{id, reason}]}`. Requires Member.

        - `fix_ids` must be non-empty and at most 100 entries (400 otherwise).
        - Failure reasons: `not_found` (an unknown, foreign-org or invisible-project fix), `not_mergeable` (the fix is not currently `mr_opened`: it never opened a merge request, or it already merged, failed or was closed), `merge_failed` (the provider rejected the merge: insufficient rights, unresolved discussions, conflicts, or an unavailable write token).
        - A successful merge moves the fix from `mr_opened` to `merged` and marks the project `running` (unless it already is); the merge itself triggers the pipeline, and no separate re-check is enqueued.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FixMergeRequest' }
      responses:
        '200':
          description: Per-item batch results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixMergeResult' }
        '400': { description: 'Malformed body, empty fix_ids, or fix_ids over the 100-item cap.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "fix request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/fix/history:
    get:
      operationId: listFixHistory
      summary: Paginated fix history, over the caller's visible projects (Member)
      description: |-
        Every fix of the caller's org, provider and instance on projects the caller can see, newest first (`created_at` descending). Every filter is optional and AND-combined. Requires Member.

        - `project` is an exact provider project path; an unknown or invisible path answers an empty page, never an error.
        - `user` and `issue_id` are exact-match ids; `status` is an exact match against the fix-status vocabulary.
        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100) paginate after visibility and filters; `total` is that count.
      security:
        - sessionCookie: []
      parameters:
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path.' }
        - { name: user, in: query, required: false, schema: { type: string, format: uuid }, description: 'Exact-match filter against the fix''s acting user id.' }
        - { name: issue_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'Exact-match filter against the fixed issue''s id.' }
        - { name: status, in: query, required: false, schema: { $ref: '#/components/schemas/FixStatus' }, description: 'Exact-match filter.' }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the caller's visible fix history.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixHistoryList' }
        '400': { description: An unrecognized status filter value. }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "fix request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /issues/{issue_id}/fix-inputs:
    get:
      operationId: getFixInputs
      summary: The inputs the components a fix would add declare (Member, visible only)
      description: |-
        For one issue, the alternatives its requiring policy configures and, per distinct component, the inputs that component declares (`spec.inputs`), read from the component template at the ref the fix would pin. Call it before `triggerFix` to know which values to collect. Requires Member.

        - Only a merge-request-mode "pipeline must include component" issue (ISSUE-408) on an onboarded project has inputs to describe; every other case, including an unknown or invisible issue and a project that is not onboarded, answers 404 with the body `no fix inputs for this issue`.
        - `groups` lists the policy's required alternatives in the same order and 0-based indexing `triggerFix`'s `inputs.group` uses. A policy with no required entries answers 200 with both lists empty.
        - `components` carries one entry per distinct component path, in first-appearance order, capped at 20 (422 beyond).
        - `readable: false` means the component's ref or template could not be read, or its spec is too large to describe; `error` says why in one sentence. `ref` is omitted when the ref itself could not be resolved. A spec is refused whole (never trimmed) when it declares more than 64 inputs, an input name over 128 bytes or outside `^[A-Za-z0-9_][A-Za-z0-9_.-]*$`, an option value over 256 bytes, or more than 64 KB of input metadata in total; a fix refuses the same components with the same message.
        - `required` means the spec declares no `default`. `type` is the spec's own type (`string`, `number`, `boolean` or `array`), `string` when omitted.
        - This is a live, rate-limited provider read: when the rate window is full or the org token cannot be used it answers 503 (`component specs unavailable, retry`); when the provider stalls past the deadline it answers 503 (`the provider did not answer in time`), never a partial answer.
        - Answers are cached: a successful one for ten minutes, a failed one for one minute; a rate-limit refusal is not cached.
      security:
        - sessionCookie: []
      parameters:
        - { name: issue_id, in: path, required: true, schema: { type: string, format: uuid }, description: 'The issue whose fix would add the components described.' }
      responses:
        '200':
          description: The alternatives, and the declared inputs of each component entry.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixInputsResponse' }
        '400': { description: 'issue_id is not a uuid, ANY query parameter was supplied (this read declares none, and an unrecognized name is named rather than silently discarded), or the requiring policy''s own `required` expression is past the bounds this platform expands (the same sentence triggerFix answers for it).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '404': { description: 'No fix inputs for this issue: the caller cannot see it, its fix adds no component, or its project is not onboarded; the cases are indistinguishable. The body is the fixed string "no fix inputs for this issue".' }
        '422': { description: 'The configured alternatives name more than 20 distinct components. The response body is the fixed string "too many components to describe".' }
        '500':
          description: 'An internal error; the body is the fixed string "fix request failed".'
        '503':
          description: 'Either the platform could not verify your session or your role with the provider (an infrastructure failure, retry later), or the component specs could not be read: the body is the fixed string "component specs unavailable, retry" when the org token is unusable or the provider rate window is full, and "the provider did not answer in time" when the read ran out of its deadline; nothing partial is served.'
  /dependencies:
    get:
      operationId: listDependencies
      summary: The pipeline resources of one kind, with who uses them (Member)
      description: |-
        One page of the resources the org's pipelines depend on, of one kind at a time: components, templates (project, remote or provider-template includes), container images, service images, runner tags or CI/CD variables, with who uses each and at which version. Requires Member.

        - Every row, count and version tally is cut to the caller's visible projects: a resource is listed only when at least one visible project uses it, and no count reveals an invisible project.
        - Keys, versions and flags are the CLI's own values, served unchanged. `outdated_count` counts the uses the CLI reported as not up to date; a bill without that flag contributes nothing. Variables come from the project snapshot and their value is never stored or served, only the provider's metadata.
        - `kind` is required and single-valued; there is no all-kinds listing. A value outside the DependencyKind vocabulary answers 400 naming the field and the allowed set.
        - `q` is a case-insensitive literal substring filter on the resource key (trimmed; `%` and `_` match literally).
        - `sort`: `consumers` (default) lists the most-used resources first, `key` lists them alphabetically; the resource id is the final tiebreak, so pagination never skips or repeats a row.
        - Keyset pagination: `after` is the `next_cursor` of a previous response; `per_page` defaults to 20 and is capped at 100 (values above 100 clamp down; zero, negative or malformed values fall back to the default). A malformed `after` answers 400; a cursor whose sort differs from the request's `sort` restarts at the first page of the new sort.
        - `total` counts every visible row matching every filter, across every page.
        - A project's entries come from its latest default-branch push carrying a bill of materials; a push without one leaves them unchanged, so a project can still be counted for a resource it has since removed. Runs on other branches never contribute.
        - Any undocumented query parameter answers 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - name: kind
          in: query
          required: true
          schema: { $ref: '#/components/schemas/DependencyKind' }
          description: 'Required, single-valued: the resource kind to list; there is no all-kinds listing. A value outside the vocabulary answers 400 naming the field and the allowed set.'
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Case-insensitive substring match on the resource key, literal (trimmed; % and _ match literally, never as wildcards); multi-word phrases match only as exact substrings. Omitted means no filter.' }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [consumers, key], default: consumers }, description: 'Ordering key for the keyset walk. consumers (default) lists the most-used resources first; key lists them alphabetically. The resource id is the final tiebreak on either key. Any other value 400s naming the field and the allowed set.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 clamp down; zero, negative or malformed values fall back to the default (20).' }
        - { name: after, in: query, required: false, schema: { type: string }, description: 'Opaque cursor from a previous response next_cursor. Omitted means the first page. A malformed value is a 400; a well-formed value whose encoded sort differs from the request sort resets cleanly to the first page of the new sort.' }
      responses:
        '200':
          description: One page of the caller's visible slice of the org's pipeline resources of that kind.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DependencyResourceList' }
        '400': { description: 'kind is absent or not a DependencyKind value, sort is not an allowed value (naming the field and the allowed set), after is malformed (a cursor with a mismatched sort is not an error), or an undocumented query parameter was supplied (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '500':
          description: 'An internal error; the body is the fixed string "dependencies request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /dependencies/{id}:
    get:
      operationId: getDependency
      summary: One pipeline resource, its consumers and its neighbourhood (Member, visible only)
      description: |-
        One pipeline resource: the attributes the CLI reported, the projects that use it (paginated) and a small neighbourhood graph. Requires Member.

        - `consumers`, every count and the whole `neighbourhood` are cut to the caller's visible projects; a resource only invisible projects use answers the same 404 as an unknown id, and invisible consumers are absent, never counted, never drawn.
        - `key`, every `version` and every `attrs` member are the CLI's own values, served unchanged. `outdated` on a neighbourhood edge is true only when the CLI reported the use as not up to date. A variable edge carries provider metadata only (masked, protected, environment, origin), never the value.
        - A consuming project is one distinct visible project (`consumer_count`); a consumer edge is one use by one project in one job (`edge_count`, `consumer_edges_total`, one entry of `consumers[]`), so `consumer_edges_total` can exceed `consumer_count`.
        - `consumers` is offset-paginated with `page` and `per_page`, ordered by project path then project id; `consumer_edges_total` counts every visible consumer edge across every page. It is not the standard list envelope.
        - `neighbourhood` is capped at 200 project nodes; beyond that it carries `truncated: true`. The resource itself is the one further node, and the edges are those of the nodes served.
        - A project's edges come from its latest default-branch push carrying a bill of materials; a push without one leaves them unchanged, so a project can still be listed as a consumer of something it has since removed. `run_id` on each consumer names the run its edge came from. Runs on other branches never contribute.
        - An unknown id, a resource of a different org, and a resource no visible project uses all answer the same 404.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Page of the consumers half. Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Consumers per page. Capped at 100; values above 100 are clamped down; zero, negative or malformed values fall back to the default (20).' }
      responses:
        '200':
          description: The resource with its visible consumers and its neighbourhood.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DependencyResourceDetail' }
        '400': { description: 'Malformed resource id (not a valid uuid), or an undocumented query parameter was supplied (named in the body).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; the body says to log in again, or that your account may have been blocked.'
        '404': { description: 'Unknown resource id, a resource belonging to a different org, or a resource no project visible to the caller uses - all three indistinguishable.' }
        '500':
          description: 'An internal error; the body is the fixed string "dependencies request failed".'
        '503':
          description: 'The platform could not verify your session or your role with the provider: an infrastructure failure, retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (grace window, default 15 minutes past the cache TTL); a write always answers 503.'
  /settings/instances/{id}:
    delete:
      operationId: deleteInstance
      summary: Delete a git instance (OrgAdmin only)
      description: |-
        Permanently removes a git instance registration. Requires OrgAdmin.

        - Refused with 409 unless the instance has no projects and is not the session's own login instance; the body names which condition blocks.
        - `{id}` must belong to the caller's org (404 otherwise, indistinguishable from an unknown id).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Deleted. }
        '400':
          description: 'Malformed id path parameter.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "settings update failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }
        '409': { description: 'Refused: the instance still has one or more projects, or it is the session''s own (login) instance; the body names which.' }

  /settings/instances/{id}/token:
    put:
      operationId: updateInstanceToken
      summary: Rotate an instance's org token (OrgAdmin only)
      description: |-
        Validates the candidate token against the git host before storing it. Requires OrgAdmin.

        - A token with insufficient scope, or an invalid or expired one, fails validation naming what is wrong; the previously active token is left untouched.
        - On success the token is stored and never displayed again.
        - `{id}` must belong to the caller's org (404 otherwise, indistinguishable from an unknown id).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateInstanceTokenRequest' }
      responses:
        '200': { description: 'Token validated and stored, but the instance is a group scope whose root group could not be resolved with it (e.g. the path does not exist or is not visible to the token). The token is kept (not rolled back) and the instance stays pending; the body carries a short diagnostic and the resolution is retried on the next token update.' }
        '204': { description: Validated and stored; the prior token (if any) is replaced. For a pending group instance this also means the root group was resolved. }
        '400':
          description: 'Candidate rejected: invalid or expired, or insufficient scope; the token must carry the `api` scope (the message names it).'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "settings update failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }
        '409': { description: 'Instance has no base_url configured yet (base_url is set via the oauth-app endpoint above; a token cannot be validated against an unknown host).' }
        '502': { description: 'Validation unavailable: the git host could not be reached, or answered with something other than a clear accept/reject.' }

  /settings/instances/{id}/root-group:
    put:
      operationId: updateInstanceRootGroup
      summary: Re-point a group-scoped instance at another root group (OrgAdmin only)
      description: |-
        Replaces the root group path of a group-scoped instance, for recovery when the configured root group was deleted, renamed or made unreadable. Requires OrgAdmin.

        - Only a `group`-scoped instance accepts this write (409 otherwise).
        - The new path replaces `root_group_path`; `root_group_id` and `root_group_name` are cleared, so the instance is pending again.
        - Resolution is attempted immediately with the stored org token (204 when resolved); when it cannot resolve yet, or no token is stored, the write still lands (200 with a short diagnostic) and the next sync run resolves the path.
        - Projects discovered under the previous root group are not deleted; the next sync re-scopes discovery under the new root.
        - `{id}` must belong to the caller's org (404 otherwise).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateInstanceRootGroupRequest' }
      responses:
        '200': { description: 'Path stored and the instance is pending: the root group could not be resolved yet with the stored token, or no token is stored. The body carries a short diagnostic; the next sync run (or the next token update) retries.' }
        '204': { description: Path stored and the root group resolved. }
        '400':
          description: 'root_group_path is required: a non-empty group path of at most 1024 characters with no control characters; otherwise the body is the fixed string "root_group_path is too long or malformed".'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }
        '409': { description: The instance is not group-scoped; only a group scope has a root group to re-point. }
        '500': { description: 'An internal failure completing the request. The response body is the fixed string "settings update failed".' }
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /settings/instances/{id}/oauth-app:
    put:
      operationId: updateInstanceOAuthApp
      summary: Configure an instance's per-instance OAuth app (OrgAdmin only)
      description: |-
        Stores the instance's OAuth app `client_id`, `client_secret` (never echoed back) and `base_url`. Requires OrgAdmin.

        - `base_url` must be a parseable absolute http(s) URL.
        - `{id}` must belong to the caller's org (404 otherwise).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateInstanceOAuthAppRequest' }
      responses:
        '204': { description: Stored. }
        '400': { description: 'Validation failed: client_id/client_secret/base_url empty, or base_url is not a parseable absolute http(s) URL.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "settings update failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }

  /settings/instances/{id}/component:
    put:
      operationId: updateInstanceComponent
      summary: Configure the component an onboarding action adds to a project (OrgAdmin only)
      description: |-
        Sets the component that onboarding adds to a project's CI file: a project path on this instance and, optionally, an immutable ref to pin. Answers 200 with a `verification` object saying whether the pin could be checked against the provider; an unverified result never blocks the write. Requires OrgAdmin.

        - `component_path` must be present: a bare project path (no URL scheme, host or `@`), or `""` to clear the setting. An absent `component_path`, or an unrecognized top-level key, is a 400.
        - `component_ref`, when present and non-empty, must be an immutable pin, never `latest`, `main`, `master` or `HEAD` (case-insensitive). Absent or `""` selects the resolved mode: the platform resolves the newest immutable tag of the component repository when it opens the onboarding MR and refreshes it as releases land.
        - A `component_ref` with an empty `component_path` is a 422: it names nothing to pin.
        - `verification.status` is `verified` when the pinned version resolved against the provider and declares the platform input (or when the setting is cleared); it is `unverified`, with `reason` naming why, when resolution could not complete (no token or base_url configured yet, the component template unreadable at this ref, the provider unreachable, or a resolved-mode save, where nothing is checked).
        - `{id}` must belong to the caller's org (404 otherwise, indistinguishable from an unknown id).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateInstanceComponentRequest' }
      responses:
        '200':
          description: 'Stored (or cleared, when both fields are explicitly empty), with the verification outcome for this write.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComponentVerification' }
        '400':
          description: 'Malformed body, an unrecognized top-level field, or `component_path` absent (the body names it); an absent `component_ref` is not a 400, it selects the resolved mode.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "settings update failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }
        '422':
          description: 'Validation failed: a floating ref, a path carrying a scheme, host or `@`, a path outside the project-path charset, a ref with an empty path, or the pinned version resolved but its spec does not declare the platform input (the body names the missing input). A resolution that fails for any other reason never blocks the write.'
  /settings/instances/{id}/integration:
    put:
      operationId: updateInstanceIntegration
      summary: Set how projects are integrated on this instance (OrgAdmin only)
      description: |-
        Chooses between the CI file method (default) and the external CI configuration method, and names the central file for the latter. Answers 200 with the instance's settings. Requires OrgAdmin.

        - Both body fields are optional; an absent field leaves the stored value unchanged. An unrecognized top-level key, or an `integration_mode` outside the enum, is a 400.
        - Every write whose resulting method is external_ci_config reads the central file with the instance token and checks that it includes this instance's component with this installation's platform input; a passing check is stored as `ok` with its time, and saving again refreshes it. A write in the CI file method reads nothing from the provider: its location is checked for form only, and the stored check is cleared.
        - The 422 message is one of: "the external CI configuration location must name a file in a project of this instance, path@group/project or path@group/project:ref"; "the external CI configuration mode needs a location"; "the external CI configuration file could not be read: ..." (the project is not visible, the file is absent at its ref, or the read failed); "the external CI configuration file does not include this instance's component with a platform input naming this install". A location longer than 512 bytes gets the first message. The last message also covers a component include whose ref cannot be read as a version, such as the snippet's `<version>` placeholder left in place. Nothing is stored on a 422.
        - Switching back to the CI file method is always accepted; projects already integrated through the central file keep it.
        - `{id}` must belong to the caller's org (404 otherwise, indistinguishable from an unknown id).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateInstanceIntegrationRequest' }
      responses:
        '200':
          description: 'Stored; the body is the instance''s settings after the write.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InstanceSettings' }
        '400': { description: 'Malformed body, an unrecognized top-level field, or an integration_mode outside the enum.' }
        '401': { description: No live session. }
        '403': { description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).' }
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }
        '422': { description: 'One of the four fixed messages listed in the operation description.' }
        '500': { description: 'Internal failure; the body is the fixed string "settings update failed" with no further detail.' }
        '503': { description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later.' }

  /settings/instances/{id}/component/version-check:
    get:
      operationId: checkInstanceComponentVersion
      summary: Check the pinned onboarding component against its latest published version (OrgAdmin only)
      description: |-
        Reports the instance's currently pinned component (`component_path`/`component_ref` as stored) alongside the newest published tag of the component repository, resolved against the provider at request time. Requires OrgAdmin.

        - `current.path`/`current.ref` echo the stored values verbatim, both `""` when nothing is configured.
        - `checked` is `true` only when the newest tag was actually resolved this request; otherwise `checked` is `false`, `latest` is `null` and `reason` is one of: `"no component configured for this instance"`, `"no org token configured yet"`, `"no base_url configured for this instance yet"`, `"component repository unreachable"`, `"could not resolve a published version for the component repository"`. `reason` is `""` exactly when `checked` is `true`.
        - `latest`, when `checked` is `true`, is `{version, ref}` (both the resolved tag name); `changelog_url` is the tags-page link for that version on the provider, nullable in the schema but never null in practice.
        - `latest` is the newest semver-parseable tag of the whole component repository, not a per-component lookup: a repository hosting several components may report a release that did not touch the pinned one.
        - No caching: every call re-resolves against the provider.
        - `{id}` must belong to the caller's org (404 otherwise).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The instance's current component pin and its resolved latest-version check.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComponentVersionCheck' }
        '400': { description: Malformed instance id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "settings request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown instance id, or an instance belonging to a different org.' }

  /settings/org:
    get:
      operationId: getOrgSettings
      summary: The org settings document (Member)
      description: |-
        Returns the caller's org's settings: the six automation and UI toggles, the branch and commit prefixes Plumber uses, and the org time zone (each field is described on the OrgSettings schema). The booleans default to false, the prefixes to empty, and `timezone` to "UTC". Requires Member.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgSettings' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "org settings request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    put:
      operationId: updateOrgSettings
      summary: Replace the org settings document (Maintainer)
      description: |-
        Replaces the caller's org's settings as a whole and returns the stored value. Requires Maintainer.

        - All nine fields are required on every write; an absent or null field is rejected.
        - `branch_prefix` and `commit_prefix` are validated (400 naming the field); a trailing "/" is appended to `branch_prefix` when absent, so "acme-sec" and "acme-sec/" are stored identically.
        - `timezone` must be an IANA zone name; an unrecognized name, "" or "Local" is a 400 naming "timezone", and a name the database does not know answers 400 "timezone: unknown to the database".
        - Changing `timezone` re-rolls the org's trend days from the earliest surviving run over the following rollup ticks (7 days per hourly tick by default); older days keep their previous day boundary (at most one day off, never deleted).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrgSettings' }
      responses:
        '200':
          description: The stored org settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgSettings' }
        '400': { description: 'Malformed request body, a required field absent or null, or an invalid branch_prefix/commit_prefix/timezone (the body names the failing field).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "org settings request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /settings/licence:
    get:
      operationId: getLicenceStatus
      summary: The org's licence status (Member)
      description: |-
        Returns the caller's org's licence state: whether a valid key is installed (`licensed`), whether it has expired (`expired`), the key's claimed `owner`/`expires_at`/`level`/`project_limit`, and the usage counted against the limit. Requires Member.

        - When `licensed` is true, those four fields carry the values claimed by the installed key; `expired` says whether they are in effect (false) or the platform has fallen back to the free tier (true).
        - When `licensed` is false (no key, or an invalid signature), the free tier is reported: `owner` and `expires_at` are `""`, `level` is 0, `project_limit` is 10.
        - `usage.counted` and `usage.project_ids` cover every project in deployment state `in progress`, `onboarded` or `unboarding`, plus any project with a live onboarding attempt in status `pending` or `mr_opened`; a project mid-onboarding counts once.
        - `over_limit` and `remaining` are computed against the limit in effect (the free tier's 10 while the key is expired), not necessarily `project_limit`: `over_limit` is `usage.counted > limit`, `remaining` is `max(0, limit - usage.counted)`. The limit is surfaced, never gated: only the onboarding wizard refuses past it (422).
        - The licence key material is never returned by any read.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's licence status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LicenceStatus' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "licence request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    put:
      operationId: applyLicence
      summary: Apply or clear the org's licence key (OrgAdmin)
      description: |-
        Validates and stores `key` as the caller's org's licence key, or clears the stored key when `key` is the empty string. Requires OrgAdmin.

        - A key whose signature does not validate, is not valid base64, or is already expired at apply time is rejected (400).
        - A key that expires after install is not rejected: from its `expires_at` on, the platform serves the free tier (project limit 10, level 0) on every read, and `expired` on the licence status read reports it.
        - The key value is never returned by any read.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApplyLicenceRequest' }
      responses:
        '204': { description: 'Stored (or cleared, when `key` is the empty string).' }
        '400': { description: 'Malformed body, `key` not valid base64, signature validation failed, or the key is structurally valid but already expired at apply time.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "licence request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /settings/ai:
    get:
      operationId: getAISettings
      summary: The org's AI/LLM connection settings (Member)
      description: |-
        Returns where the caller's org's own LLM lives (provider and connection fields) plus three presence booleans, `api_key_set`, `bedrock_access_keys_set` and `bedrock_api_key_set`; secret values are never returned. Requires Member.

        - The platform does not call an LLM with this configuration; it only stores it.
        - `provider` defaults to `openai` until an OrgAdmin configures one; `enabled` stays false until the provider's completeness set is met (see updateAISettings).
        - `bedrock_session_token` is writable but its presence is not reported by any read.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's AI settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AISettings' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "AI settings request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    put:
      operationId: updateAISettings
      summary: Partial update of the org's AI/LLM connection settings (OrgAdmin)
      description: |-
        Partial update: every field is optional, an absent field keeps its stored value, and an explicit empty string clears it. `enabled` is the one non-string field and keeps its value when absent. Requires OrgAdmin.

        - The platform does not call an LLM with this configuration; it only stores it.
        - `enabled` can only be true when the provider's completeness set is met by the state resulting from this write (stored values included): for `openai`, `endpoint`, `model`, `model_light` and an api key; for `bedrock`, `bedrock_region`, `bedrock_model`, `bedrock_model_light` and either `bedrock_api_key` or the pair `bedrock_access_key_id` + `bedrock_secret_access_key`. The check runs on every write whose resulting `enabled` is true, so clearing a required field on an enabled org is a 400 naming it.
        - `provider`, when supplied, must be `openai` or `bedrock`; `bedrock_auth_method`, when supplied, must be `""`, `apiKey` or `accessKeys`.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateAISettingsRequest' }
      responses:
        '204': { description: 'Stored (partial update applied field by field).' }
        '400': { description: 'Malformed body, an invalid provider/bedrock_auth_method enum value, or `enabled: true` requested while its provider''s completeness set is not met (the body names the missing fields).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "AI settings request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /onboarding/status:
    get:
      operationId: getOnboardingStatus
      summary: The onboarding wizard's status (Member)
      description: |-
        Returns the caller's org's stored onboarding "done" flag plus eight signals derived live from existing data (each is described on the OnboardingStatus schema). Requires Member.

        - `token_set`: any git instance of the org has an org token stored.
        - `sync_has_run`: a sync run has completed successfully for the org.
        - `any_project_onboarded`: any project has deployment status `onboarded`.
        - `licence_set`: a licence key is stored and its signature validates; expiry is not checked, unlike `licensed` on the licence status read.
        - `component_set`: any git instance has a `component_path` configured (the ref is not required).
        - `any_policy_created`: at least one policy exists for the org.
        - `ai_configured`: the AI settings are currently enabled.
        - `licence_exceeded`: the counted projects exceed the project limit in effect (the free tier's 10 while the key is expired), the same figure the licence status read reports as `over_limit`.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's onboarding status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OnboardingStatus' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "onboarding status request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    put:
      operationId: updateOnboardingStatus
      summary: Mark the onboarding wizard done or not done (OrgAdmin)
      description: |-
        Stores the caller's org's onboarding "done" flag exactly as given; marking done and reverting to not-done are both valid. The derived signals of the status read are unaffected. Requires OrgAdmin.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateOnboardingStatusRequest' }
      responses:
        '204': { description: Stored. }
        '400': { description: 'Malformed body, or an unrecognized top-level field (named).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "onboarding status request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /settings/authorization/groups:
    get:
      operationId: listAuthorizedMaintainerGroups
      summary: List the org's authorized-maintainer groups (Maintainer), paginated
      description: |-
        Lists every git group an OrgAdmin has authorized as Maintainer-granting for the caller's org and instance, with the group's path as last synced (no provider call). Requires Maintainer.

        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100) paginate; `total` counts every match across every page.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the caller's org's authorized-maintainer groups.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthorizedMaintainerGroupList' }
        '400': { description: 'An unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "authorization request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    post:
      operationId: createAuthorizedMaintainerGroup
      summary: Authorize a group as Maintainer-granting (OrgAdmin)
      description: |-
        Grants the Maintainer role, from the next request on, to every member of `provider_group_id` (at any provider access level above none) on the caller's instance. Requires OrgAdmin.

        - `provider_group_id` must already be known to this instance through sync; a group never synced, or synced under a different org, is rejected (422): sync it first.
        - Authorizing the same group twice is rejected (409).
        - The number of grants per instance is bounded; reaching the bound is rejected (422).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AuthorizedMaintainerGroupCreateRequest' }
      responses:
        '201':
          description: Authorized.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthorizedMaintainerGroup' }
        '400': { description: 'Malformed body: provider_group_id missing or empty.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "authorization request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '409': { description: This group is already authorized for the org. }
        '422': { description: 'provider_group_id is not known to this instance yet (sync the group first), or the org already holds the maximum number of authorized groups.' }

  /settings/authorization/groups/{providerGroupId}:
    delete:
      operationId: deleteAuthorizedMaintainerGroup
      summary: Revoke a group's Maintainer-granting authorization (OrgAdmin)
      description: |-
        Revokes the grant: members of the group stop deriving Maintainer from it on the next request. Requires OrgAdmin.

        - `{providerGroupId}` must belong to a grant on the caller's org and instance (404 otherwise).
      security:
        - sessionCookie: []
      parameters:
        - { name: providerGroupId, in: path, required: true, schema: { type: string } }
      responses:
        '204': { description: Revoked. }
        '400':
          description: 'Malformed id path parameter.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "authorization request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: No such grant for the caller's own org and instance. }

  /settings/authorization/synced-groups:
    get:
      operationId: listSyncedGroups
      summary: List the org's synced git groups, as last synced (Maintainer)
      description: |-
        Lists every git group sync has observed for the caller's org across every connected instance, as last synced (no provider call), in the standard page envelope: `{items: [{provider_group_id, full_path, name}], page, per_page, total}`. Requires Maintainer.

        - Values reflect the group as of its last sync; a group synced or renamed more recently is not yet reflected. Being listed here does not guarantee `createAuthorizedMaintainerGroup` accepts the group: one synced under a different instance or org, or not synced yet, still gets a 422 there.
        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100) paginate.
        - `provider` and `instance` filter independently; omitted, the listing is org-wide.
        - `q` is a case-insensitive literal substring filter on `name` or `full_path`. Filters and pagination combine.
        - An unknown query parameter is a 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: provider, in: query, required: false, schema: { type: string }, description: 'Filter on the group''s provider, independent of instance; omitted means every provider.' }
        - { name: instance, in: query, required: false, schema: { type: string }, description: 'Filter on the group''s instance, independent of provider; omitted means every instance.' }
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Substring match against name or full_path, case-insensitive, literal (trimmed; % and _ match literally, never as wildcards; a raw NUL byte is a 400). Omitted means no filter.' }
      responses:
        '200':
          description: One page of the caller's org's synced groups, as last synced.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncedGroupList' }
        '400': { description: 'q contains a raw NUL byte (naming the parameter and the code point), or an unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "authorization request failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /controls:
    get:
      operationId: listControls
      summary: The read-only control library of the pinned CLI (Member)
      description: |-
        Every control the pinned CLI build recognizes, one item per control sorted by `type`, with its typed configuration fields and catalog metadata (`id`, `category`, `providers`, `description`, `requires_config`, `requires_tier`, `default_config`, `issue_codes`). Requires Member.

        - A control the pinned CLI declares for `github` only is not listed: the platform serves GitLab. A policy content write naming one drops the entry with a `provider_unsupported` warning. A control declared for `gitlab`, alone or beside `github`, is listed with its `providers` array verbatim.
        - The library changes only when a deployment pins a new CLI version, never at runtime.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The full control library, sorted by type.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ControlLibraryList' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /issue-types:
    get:
      operationId: listIssueTypes
      summary: The read-only issue-type catalog of the pinned CLI (Member)
      description: |-
        Every issue type the pinned CLI build declares: `code`, `severity`, `title`, `description`, `remediation` and the owning control's `control_type`, sorted by `code`. Render these fields directly rather than keeping your own copy of the wording. Requires Member.

        - Join an entry to an issue by `code` (`IssueSummary.code`, the finding's `ISSUE-nnn` code); `control_type` alone cannot disambiguate a control owning several issue types.
        - An issue type whose owning control the platform does not serve is not listed (the platform serves GitLab). An issue can still carry such a code (a GitHub project pushing results, or a CLI newer than the platform's pin): a code with no catalog entry is not an error, fall back to rendering the `code` itself.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The full issue-type catalog, sorted by code.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueTypeCatalogList' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /policy-templates:
    get:
      operationId: listPolicyTemplates
      summary: The fixed policy template catalog (Member)
      description: |-
        A fixed catalog of starter policies pre-filled with a recognized framework's control groups and controls: ISO27001 & ISO27002, SecNumCloud (SnC), OWASP, and a blank starter. Each entry's `content` has the shape `PUT /config/policies/{id}/content` accepts: to create a policy from a template, call `createPolicy` for the metadata, then `replacePolicyContent` with the template's `content`. Requires Member.

        - Always exactly four items, in a fixed order: `iso27001`, `secnumcloud`, `owasp`, `blank`. The catalog never changes at runtime and carries no ids.
        - `blank`'s `content.control_groups` is an empty array; its `color` is `#6b7280`.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The full, fixed template catalog.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyTemplateList' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /config/policies:
    get:
      operationId: listPolicies
      summary: List the caller's org's policies (metadata + counts), paginated
      description: |-
        Every policy defined for the caller's org, alphabetical by name. Requires Member.

        - The [Plumber default] policy is never listed: it is derived and report-only, not editable.
        - On a fresh install three packs (ISO27001 & ISO27002, SecNumCloud (SnC), OWASP) are seeded once for the default org and appear like any other policy: an org may edit or delete them, and an upgrade never re-creates them.
        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100) paginate; `total` counts every match across every page.
        - `q` is a case-insensitive literal substring filter on `name`. The score trend read (`GET /config/policies/score-history`) takes ids from this list and has no search parameter of its own.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Substring match against name, case-insensitive, literal (trimmed; % and _ match literally, never as wildcards); multi-word phrases match only as exact substrings.' }
      responses:
        '200':
          description: One page of the caller's org's policies.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyList' }
        '400': { description: 'An unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
    post:
      operationId: createPolicy
      summary: Create a policy (metadata only - content and assignments are separate calls)
      description: |-
        Creates a policy with no control groups, controls or assignments yet; add them afterwards via `policies/{id}/content` and `policies/{id}/assignments`. Requires Maintainer.

        - `name` is required, non-empty and unique within the org (case-sensitive); a duplicate is a 409.
        - `color` defaults to #3b82f6 when omitted or empty.
        - `enforcement` must be `block` or `report`.
        - A create marks nothing stale: nothing is assigned to it yet.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyMetaRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedID' }
        '400': { description: 'Validation failed, naming the field (e.g. "name is required", "enforcement must be block or report", "min_points must not be negative").' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '409': { description: 'A policy with this name already exists in this org (per-org name uniqueness, case-sensitive).' }

  /config/policies/scope-preview:
    post:
      operationId: previewPolicyScope
      summary: Preview the projects an unsaved (or saved) scope filter would govern, without writing anything
      description: |-
        Resolves the projects a scope filter would govern, without writing anything: a read despite the POST verb, using the same predicate the saved `scope_filter` uses, so preview and save never diverge. Requires Maintainer.

        - Exactly one of `policy_id` (preview against that policy's saved direct and portfolio assignments) or the explicit form (`project_ids` and/or `portfolio_ids`) must be supplied; both or neither is a 400.
        - `scope_filter` is required but nullable: null previews the unfiltered candidate set (`matched_count` equals the candidate count, `unclassified_count` is 0).
        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100) ride the request body; `total` always equals `matched_count`.
        - `items` only lists the projects the caller can see; `matched_count`/`excluded_count`/`unclassified_count` are org-level. In the explicit form, submitted `project_ids` the caller cannot see are dropped before resolution and count nowhere, indistinguishable from unknown ids; `portfolio_ids` are not filtered.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyScopePreviewRequest' }
      responses:
        '200':
          description: The would-be governed project set for the given (unsaved or saved) filter.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyScopePreviewResponse' }
        '400': { description: 'Validation failed: `scope_filter` absent entirely (it is required; an explicit null is valid and previews the unfiltered candidate set), neither or both of policy_id/explicit ids supplied, a cap exceeded (project_ids over 500, portfolio_ids over 100), or an invalid scope_filter payload (the same caps/rules PolicyScopeFilter documents) - naming the field.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '404': { description: 'Unknown policy_id, or a policy_id belonging to a different org.' }
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is. The visible project set may fail to resolve for the same reason.'
  /config/policies/score-history:
    get:
      operationId: policyScoreHistoryBatch
      summary: Daily score trend for several policies in one call (Member)
      description: |-
        The batch form of `GET /config/policies/{id}/score-history`: several policies' daily score series, in org-calendar days, in one call. Per requested id the semantics are those of the single-policy read: 90-day default range, 400-day cap, `from` not after `to`, gaps stay gaps, ascending days, one live point for today when the range includes it (omitted when it cannot be computed), earlier days from the stored daily rollup. Requires Member.

        - `ids` is a repeated query parameter, 1 to 100 entries; zero, more than 100, or a malformed entry is a 400.
        - An unknown id, or one belonging to a different org, is not an error: it yields an entry with an empty inner `items` list.
        - `from`/`to` are calendar dates in the org calendar, both optional (default: the last 90 days ending today), one range shared across every id.
        - Days are the org's own calendar (its `timezone` setting), the same for every viewer: render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
        - An undocumented query parameter is a 400 naming it.
      security:
        - sessionCookie: []
      parameters:
        - name: ids
          in: query
          required: true
          style: form
          explode: true
          schema: { type: array, items: { type: string, format: uuid }, minItems: 1, maxItems: 100 }
          description: 'Repeated `ids=<uuid>&ids=<uuid>...`, 1 to 100 entries. An id unknown, or belonging to a different org, yields an empty-items entry rather than an error - never distinguishable from one another.'
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date lower bound (inclusive), read in the org calendar, shared across every id. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date upper bound (inclusive), read in the org calendar, shared across every id. Defaults to the org today.' }
      responses:
        '200':
          description: Each requested policy's daily score trend, in request order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistoryBatch' }
        '400': { description: '`ids` missing/empty/over 100 entries/containing a malformed uuid, a malformed from/to date, from after to, a resolved range over 400 days, or an undocumented query parameter was supplied (naming it).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail, or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
  /config/policies/{id}:
    get:
      operationId: getPolicy
      summary: Full policy detail - metadata, control groups/controls, and assignments
      description: |-
        Returns the policy's metadata, control groups and controls, and assignments. Requires Member.

        - `{id}` must belong to the caller's org (404 otherwise; an unknown id and another org's id are indistinguishable).
        - `assignments.project_ids` is the first page of the direct assignment set (capped at 100, ordered by project_path then id), limited to the projects the caller can see, so it may be shorter than `assignments.assigned_project_count`; `GET /config/policies/{id}/projects` pages through the rest. `assignments.portfolio_ids` is neither filtered nor paginated.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Full policy detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyDetail' }
        '400': { description: Malformed policy id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is. The visible project set may fail to resolve for the same reason.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }
    put:
      operationId: updatePolicyMeta
      summary: Update a policy's metadata (name, description, color, enforcement, min_points)
      description: |-
        Updates the metadata only; control groups, controls and assignments have their own PUT endpoints. Requires Maintainer.

        - A name, description, color, enforcement or min_points change marks nothing stale. A `scope_filter` or `applies_to` change is a membership change: exactly the projects entering or leaving the governed set go stale, and a flip of `applies_to` to `assigned` closes the issues of every project that loses the policy.
        - `applies_to` keeps its stored value when absent; every other field is replaced.
        - Last save wins: concurrent edits are not merged.
        - `name` must stay unique within the org (case-sensitive); renaming onto an existing name is a 409 and nothing is updated.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyMetaRequest' }
      responses:
        '204':
          description: 'Updated; only the projects entering or leaving the governed set are marked stale.'
        '400': { description: 'Validation failed, naming the field.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }
        '409': { description: 'A policy with this name already exists in this org (per-org name uniqueness, case-sensitive).' }
    delete:
      operationId: deletePolicy
      summary: Delete a policy (cascades its control groups, controls, and assignments)
      description: |-
        Deletes the policy and cascades its control groups, controls and assignments. Requires Maintainer.

        - The projects the policy governed (its scope-filtered set) are marked stale; a project the scope filter excluded is not.
        - Issues referencing the deleted policy are closed across the whole org; each affected project receives a `project.update` event carrying its `issues_closed`.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204':
          description: 'Deleted; the governed projects are marked stale and every project whose issues were closed receives a project.update event.'
        '400': { description: Malformed policy id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the body says to log in again).'
        '409':
          description: 'A referenced id is unknown, belongs to a different org, or is outside what the caller can see.'
        '500':
          description: 'Internal failure; the body is the fixed string "config write failed" with no further detail.'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a read may still be served from a bounded role cache (default 15 minutes past the cache TTL), a write never is.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }

  /config/policies/{id}/projects:
    get:
      operationId: listPolicyProjects
      summary: The policy's direct coverage, with per-policy score and open issue count, paginated
      description: |-
        The projects the policy directly covers: its explicit project assignments or, when `applies_to` is `all`, every live project of the org within its scope filter (archived and provider-deleted projects excluded). Projects reached only through a portfolio are not listed (see `PolicyAssignmentsPreview.portfolio_reach_count`). Offset-paginated (`page`/`per_page`); deep pages may be slow on an `all` policy. Requires Member or above.

        - `{id}` must belong to the caller's own org (404 otherwise; unknown ids and ids belonging to a different org are indistinguishable).
        - `items` only lists the projects the caller can currently see; `total` is not filtered this way.
        - `page` (default 1, minimum 1) and `per_page` (default 20, capped at 100).
        - Each row carries `name`/`name_with_namespace`, this policy's own per-project score (`policy_score_points`/`policy_score_letter`/`policy_score_withheld`, taken from the project's latest run and distinct from the project's global score; the third field says whether that run evaluated any control of this policy) and `open_issue_count` (issues requiring this policy only, never the project's total).
        - A project the policy's scope filter excludes is not returned, and `total` moves with it.
        - `sort`/`order`: closed, case-sensitive enums. `sort=policy_score_points&order=asc` (the default) is worst-first. A project this policy has never scored (empty `policy_score_letter`) sorts last regardless of `order`; a withheld row (`policy_score_withheld` true) orders with the zeros, the flag saying that 0 is not a grade. `path` orders by the row's `path`. `id` is always the final tiebreak.
        - `freshness`/`language`/`last_active_since`/`last_active_before`/`archived`/`provider_deleted`: the same six facets as `GET /projects`, narrowing this list on top of the scope filter; `total` narrows identically. Under `applies_to = all`, `archived=true` or `provider_deleted=true` only returns dead projects the policy still governs through a direct assignment.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: page, in: query, schema: { type: integer } }
        - { name: per_page, in: query, schema: { type: integer } }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [policy_score_points, path], default: policy_score_points }, description: 'Sort key. policy_score_points orders by this policy''s own per-project score, worst-first by default; a project this policy has never scored sorts last regardless of order. path orders by the row''s path. Default policy_score_points. id is always the final tiebreak. Any other value is a 400 naming the field and the allowed set.' }
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc }, description: 'Sort direction, paired with sort. Default asc; sort=policy_score_points&order=asc (the default pair) is worst-first. Any other value is a 400 naming the field and the allowed set.' }
        - name: freshness
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/Freshness' } }
          description: 'Optional multi-value filter, the same repeated-parameter shape as GET /projects: freshness=<value>&freshness=<value>..., each validated against the Freshness vocabulary, multiple values OR-ed together. Omitted means no filter.'
        - { name: language, in: query, required: false, schema: { type: string }, description: 'Matches a key of the row''s languages map, case-insensitive. A row with no languages collected yet never matches.' }
        - { name: last_active_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on last_activity_at (UTC calendar date); a row with no last_activity_at is excluded.' }
        - { name: last_active_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on last_activity_at (UTC calendar date); a row with no last_activity_at is excluded.' }
        - name: archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: archived rows are excluded, as in GET /projects. true opts them back in, independently of provider_deleted. Any value other than "true" or "false" is a 400.'
        - name: provider_deleted
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: provider-deleted rows are excluded, as in GET /projects. true opts them back in, independently of archived. Any value other than "true" or "false" is a 400.'
      responses:
        '200':
          description: One page of the policy's direct coverage.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyProjectsPage' }
        '400': { description: 'Malformed policy id (not a valid uuid), sort/order is not one of its own closed, case-sensitive vocabulary values, or freshness/archived/provider_deleted carries an unrecognized value, or last_active_since/last_active_before is not a valid YYYY-MM-DD date - naming the field and, for the closed vocabularies, the allowed set.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or could not resolve the caller''s visible project set with the provider; retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }

  /config/policies/{id}/score-history:
    get:
      operationId: policyScoreHistory
      summary: Daily score trend for a policy
      description: |-
        A bounded date series in the org calendar, one point per day the policy has data for (not the standard list envelope). Requires Member or above.

        - `{id}` must belong to the caller's own org (404 otherwise; unknown ids and ids belonging to a different org are indistinguishable).
        - `from`/`to` are calendar dates in the org calendar (`YYYY-MM-DD`, never a date-time). Both optional: `to` defaults to today, `from` to `to` minus 89 days. The range must not exceed 400 days and `from` must not be after `to` (400 otherwise).
        - Each `day` is the average per-policy score over every project whose last completed run at or before that day's end carries this policy, rounded half-up to one decimal; never a per-project breakdown. A day with no contributing project is absent from `items` (a gap), never a zero-value point.
        - If the range includes today, one freshly computed point for today is appended; if that computation fails, the point is omitted and every stored day is still returned.
        - `items` is ordered by `day` ascending; a gap is never backfilled or interpolated.
        - Days are the org's own calendar (its `timezone` setting), the same for every viewer; render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date lower bound (inclusive), read in the org calendar. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date upper bound (inclusive), read in the org calendar. Defaults to the org today.' }
      responses:
        '200':
          description: The policy's daily score trend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistory' }
        '400': { description: 'Malformed policy id, a malformed from/to date, from after to, or a resolved range over 400 days.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail, or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }

  /config/policies/{id}/control-breakdown:
    get:
      operationId: policyControlBreakdown
      summary: Per-control-type pass/fail/not_evaluable counts for a policy, as of now
      description: |-
        For each control type appearing in the policy, the summed finding-status counts (pass/fail/not_evaluable) across the current run of every org project whose latest run carries this policy. The counts are the statuses the CLI pushed, never a platform-side re-evaluation. Requires Member or above.

        - `{id}` must belong to the caller's own org (404 otherwise; unknown ids and ids belonging to a different org are indistinguishable).
        - Per-control rows exist only for pushes made since this feature became available: a project scored before then contributes nothing until its next push. A control type with no contributing rows does not appear in `items`.
        - `project_count` is the number of distinct projects contributing to that control type's counts.
        - A control type the platform does not serve for its provider is omitted; an unknown control type (a newer CLI's control) appears with its counts.
        - `items` is ordered by `control_type` ascending.
        - `portfolio_id` (optional) restricts the counts to projects that are both in that portfolio's current resolved project set and carry this policy in their current run. It must belong to the caller's own org (404 otherwise). Omitted, every org project whose current run carries the policy counts.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: portfolio_id, in: query, required: false, schema: { type: string, format: uuid }, description: 'Restricts the summed counts to this portfolio''s current resolved project set intersected with the policy''s current coverage. Must belong to the caller''s own org (404 otherwise).' }
      responses:
        '200':
          description: The policy's per-control-type breakdown, as of now.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyControlBreakdown' }
        '400': { description: 'Malformed policy id, or a malformed portfolio_id (not a valid uuid).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown policy id, a policy belonging to a different org, an unknown portfolio_id, or a portfolio_id belonging to a different org.' }

  /config/policies/{id}/content:
    put:
      operationId: replacePolicyContent
      summary: Full replace of a policy's control groups and controls (whole form)
      description: |-
        The request body is the complete desired state, never a diff; the replace is atomic.

        - Position is derived from array order for both control groups and controls, never a caller-supplied index.
        - `control.config` must be valid JSON.
        - Every control whose type appears on `GET /controls` has its `config` validated against the schema served for that type (keys, field types, enum values, nulls, integer range); one 400 lists every violation across the whole body. A valid config is stored byte-for-byte. An unknown control type is stored as-is, unvalidated.
        - Reads are untouched: a config stored before this validation existed, or made invalid by a later CLI version, is still served verbatim by `GET /config/policies/{id}` and `/context`.
        - A successful replace marks the policy's governed projects (the scope-filtered set) stale; an assigned project the scope filter excludes is not staled.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyContentRequest' }
      responses:
        '200':
          description: 'Replaced; the policy''s governed projects are marked stale and a project.update event is emitted for each. The body is always present, `{"warnings": []}` at minimum: a config key the served schema no longer knows (a field renamed or removed by a newer CLI version) is accepted, stored and reported as a warning.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PolicyContentResult' }
        '400':
          description: 'Validation failed, naming the field. Two body shapes, both JSON objects: (1) structural failures carry `{"message": "<field> ..."}`: control_groups absent from the body (an explicit empty array is a valid full clear), an unrecognized top-level field, a control group with an empty name, a control missing type/version/config, a config that is not a JSON object, or a control type repeated in the policy; (2) control-config validation carries `{"error": "<one sentence>", "violations": [ConfigValidationError, ...], "violations_total": <integer>}`, listing every violation found across the whole body in body order, capped at 100 entries, with `violations_total` the true, uncapped count. Nothing is written in either case.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '409':
          description: 'A referenced id is unknown, belongs to a different org, or is not visible to the caller.'
        '422':
          description: 'Two control groups in the same request body share a name (exact match after trimming whitespace); names identify a control group in `/context`. The body is the ordinary `{"message": "..."}` shape carrying `control_groups[<i>].name "<name>" duplicates control_groups[<first>]; names are the wire identity of a control group in /context`. Nothing is written.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }

  /config/policies/{id}/assignments:
    put:
      operationId: replacePolicyAssignments
      summary: Full-set replace of a policy's project and portfolio assignments
      description: |-
        The request body is the complete desired assignment set for every project the caller can see, never a diff. A project the caller cannot see keeps its assignment as it was: leaving it out of the body does not remove it. Submitting the id of a project the caller cannot see is rejected (409), never silently accepted.

        - Only the projects entering or leaving the policy's governed set are marked stale. A project this write assigns that the policy's `scope_filter` excludes is assigned but not staled. The caller never computes the diff itself.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyAssignments' }
      responses:
        '204':
          description: 'Replaced; the projects entering or leaving the governed set are marked stale and a project.update event is emitted for each.'
        '400': { description: 'Validation failed, naming the field: project_ids and/or portfolio_ids absent from the body (an explicit empty array is a valid full clear of that half of the set), an unrecognized top-level field (for example a misnamed key), or a malformed project/portfolio id.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or could not resolve the caller''s visible project set with the provider; retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown policy id, or a policy belonging to a different org.' }
        '409': { description: 'A referenced project_id or portfolio_id is unknown, belongs to a different org than the policy, or (project_id only) is not visible to the caller. No partial write; the whole replace rolls back.' }

  /config/portfolios:
    get:
      operationId: listPortfolios
      summary: List the caller's org's portfolios (metadata + counts), paginated
      description: |-
        Every portfolio defined for the caller's org, alphabetical by name.

        - `page` (default 1, minimum 1, values below 1 are clamped up) and `per_page` (default 20, capped at 100) paginate the org's portfolios; `total` reflects every match across every page.
        - Requires Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the caller's org's portfolios.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PortfolioList' }
        '400': { description: 'An unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
    post:
      operationId: createPortfolio
      summary: Create a portfolio (metadata only; projects and rules are separate calls)
      description: |-
        Creates an empty portfolio (no explicit projects, no group rules yet).

        - `name` is mandatory, non-empty, and unique within the caller's org (case-sensitive): a duplicate name is a 409.
        - `color` defaults to #10b981 when omitted or empty.
        - `policy_ids` optionally seeds the portfolio's initial policy set in the same call.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioCreateRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedID' }
        '400': { description: 'Validation failed, naming the field (e.g. "name is required", or "policy_ids: at most 100 entries").' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '409': { description: 'A portfolio with this name already exists in this org (case-sensitive), or the portfolio''s policy set changed concurrently (nothing was written, the body is "the configuration changed concurrently, retry the request"); retry.' }
        '404': { description: 'A policy_ids entry is unknown or belongs to a different org ("policy not found"); no portfolio is created.' }

  /config/portfolios/score-history:
    get:
      operationId: portfolioScoreHistoryBatch
      summary: Daily score trend for several portfolios in one call (Member)
      description: |-
        The batch form of `GET /config/portfolios/{id}/score-history`: several portfolios' daily score series in one call, with the same semantics per requested id (90-day default, 400-day cap, gaps stay gaps, ascending days, one live point for today computed against the portfolio's current project set when the range includes today; a failed live computation omits that point). Requires Member or above.

        - `ids` is a repeated query parameter (`ids=<uuid>&ids=<uuid>...`), required, 1 to 100 entries. Zero, more than 100, or a malformed entry is a 400.
        - An unknown id, or an id belonging to a different org, is never an error: it yields its own entry in `items` with an empty inner `items` list.
        - `from`/`to` behave exactly like the single-portfolio read (calendar dates in the org calendar, both optional, default the last 90 days ending today, at most 400 days, `from` not after `to`); one resolved range is shared across every id (see ScoreHistoryBatch.from/to).
        - An undocumented query parameter is a 400 naming it.
        - Days are the org's own calendar (its `timezone` setting), the same for every viewer; render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
      security:
        - sessionCookie: []
      parameters:
        - name: ids
          in: query
          required: true
          style: form
          explode: true
          schema: { type: array, items: { type: string, format: uuid }, minItems: 1, maxItems: 100 }
          description: 'Repeated `ids=<uuid>&ids=<uuid>...`, 1 to 100 entries. An id unknown, or belonging to a different org, yields an empty-items entry rather than an error - never distinguishable from one another.'
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date lower bound (inclusive), read in the org calendar, shared across every id. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date upper bound (inclusive), read in the org calendar, shared across every id. Defaults to the org today.' }
      responses:
        '200':
          description: Each requested portfolio's daily score trend, in request order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistoryBatch' }
        '400': { description: '`ids` missing/empty/over 100 entries/containing a malformed uuid, a malformed from/to date, from after to, a resolved range over 400 days, or an undocumented query parameter was supplied (naming it).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail, or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
  /config/portfolios/{id}:
    get:
      operationId: getPortfolio
      summary: Full portfolio detail - metadata, projects, group rules, and the resolved-project preview
      description: |-
        `resolved_projects` is the first page of the live resolved set: explicit projects UNION every project matching a group: rule, computed at read time, capped at 100, ordered by project_path then id.

        - `{id}` must belong to the caller's own org (404 otherwise).
        - Requires Member level or above.
        - `resolved_projects` only lists the projects the caller can currently see; a different caller with different project access may see a different list for the same portfolio, and its length may be smaller than `resolved_count` (which is itself the same, not-visibility-filtered value the list endpoint's `resolved_project_count` reports). See `GET /config/portfolios/{id}/projects` for the rest, keyset-paginated. `projects` and `rules` are not filtered or paginated this way (they describe the portfolio's own configuration, not a set of projects to read).
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Full portfolio detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PortfolioDetail' }
        '400': { description: Malformed portfolio id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or could not resolve the caller''s visible project set with the provider; retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }
    put:
      operationId: updatePortfolioMeta
      summary: Update a portfolio's metadata (name, color)
      description: |-
        Metadata only; projects and rules have their own PUT endpoints.

        - Name and color are display-only, so a successful update marks nothing stale and emits no `project.update` event.
        - Last-save-wins: concurrent edits are not merged, the most recent write wins.
        - `name` must stay unique within the caller's org (case-sensitive): renaming onto a name another portfolio already holds is a 409, the whole update rejected.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioMetaRequest' }
      responses:
        '204':
          description: 'Updated; nothing is marked stale and no project.update event is emitted.'
        '400': { description: 'Validation failed, naming the field.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }
        '409': { description: 'A portfolio with this name already exists in this org (per-org name uniqueness, case-sensitive).' }
    delete:
      operationId: deletePortfolio
      summary: Delete a portfolio (projects stay onboarded; policy links through it dissolve)
      description: |-
        Deleting a portfolio never touches the projects it resolved to: they stay onboarded.

        - Every project that lost policy coverage because it resolved through this portfolio is marked stale.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: 'Deleted; affected projects staled, project.update emitted for each.' }
        '400': { description: Malformed portfolio id (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '409':
          description: 'A referenced id is unknown, belongs to a different org, or is not visible to the caller.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }

  /config/portfolios/{id}/projects:
    get:
      operationId: listPortfolioProjects
      summary: The portfolio's full resolved-project set, paginated
      description: |-
        The portfolio's full resolved-project set: explicit projects plus every project matching a group rule, computed at read time. Keyset-paginated (`per_page`/`after`), since a single group rule can match an entire fleet. Requires Member or above.

        - `{id}` must belong to the caller's own org (404 otherwise; unknown ids and ids belonging to a different org are indistinguishable).
        - `items` only lists the projects the caller can currently see; `total` is not filtered this way.
        - `per_page` (default 20, capped at 100). `after` is the opaque cursor from a previous page's `next_cursor`; omitted or empty starts from the first page. A malformed `after` is a 400, never silently treated as the first page.
        - `freshness`/`language`/`last_active_since`/`last_active_before`/`archived`/`provider_deleted`: the same six facets as `GET /projects`, narrowing this portfolio's resolved set; `total` narrows identically.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: per_page, in: query, schema: { type: integer } }
        - { name: after, in: query, schema: { type: string } }
        - name: freshness
          in: query
          required: false
          style: form
          explode: true
          schema: { type: array, items: { $ref: '#/components/schemas/Freshness' } }
          description: 'Optional multi-value filter, the same repeated-parameter shape as GET /projects: freshness=<value>&freshness=<value>..., each validated against the Freshness vocabulary, multiple values OR-ed together. Omitted means no filter.'
        - { name: language, in: query, required: false, schema: { type: string }, description: 'Matches a key of the row''s languages map, case-insensitive. A row with no languages collected yet never matches.' }
        - { name: last_active_since, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive lower bound on last_activity_at (UTC calendar date); a row with no last_activity_at is excluded.' }
        - { name: last_active_before, in: query, required: false, schema: { type: string, format: date }, description: 'Inclusive upper bound on last_activity_at (UTC calendar date); a row with no last_activity_at is excluded.' }
        - name: archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: archived rows are excluded, as in GET /projects. true opts them back in, independently of provider_deleted. Any value other than "true" or "false" is a 400.'
        - name: provider_deleted
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Defaults to false: provider-deleted rows are excluded, as in GET /projects. true opts them back in, independently of archived. Any value other than "true" or "false" is a 400.'
      responses:
        '200':
          description: One page of the portfolio's resolved-project set.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PortfolioProjectsPage' }
        '400': { description: 'Malformed portfolio id (not a valid uuid), a malformed `after` cursor, or freshness/archived/provider_deleted carries an unrecognized value, or last_active_since/last_active_before is not a valid YYYY-MM-DD date - naming the field and, for the closed vocabularies, the allowed set.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or could not resolve the caller''s visible project set with the provider; retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }
    put:
      operationId: replacePortfolioProjects
      summary: Full-set replace of a portfolio's explicit projects
      description: |-
        The request body is the complete desired explicit-project set for every project the caller can see, never a diff. A project the caller cannot see stays included as before: leaving it out of the body does not remove it. Submitting the id of a project the caller cannot see is rejected (409), never silently accepted.

        - Only the projects entering or leaving the resolved set are marked stale; group-rule matches are unaffected by this call.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioProjectsRequest' }
      responses:
        '204':
          description: 'Replaced; the projects entering or leaving the resolved set are marked stale and a project.update event is emitted for each.'
        '400': { description: 'Validation failed, naming the field: project_ids absent from the body (an explicit empty array is a valid full clear), an unrecognized top-level field, or a malformed project id.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or could not resolve the caller''s visible project set with the provider; retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }
        '409': { description: 'A referenced project_id is unknown, belongs to a different org than the portfolio, or is not visible to the caller. No partial write; the whole replace rolls back.' }

  /config/portfolios/{id}/excluded-projects:
    put:
      operationId: replacePortfolioExcludedProjects
      summary: Full-set replace of the projects excluded from a portfolio's rule matches
      description: |-
        Excludes specific projects from what the portfolio's group rules match, without giving up the rule. The resolved set becomes `(projects UNION rule matches) MINUS excluded_projects`, and every surface that reads membership applies the same subtraction (`resolved_count`, `resolved_projects`, `GET /config/portfolios/{id}/projects`, `PortfolioSummary.resolved_project_count`, `ProjectListItem.portfolio_count`/`portfolio_ids`, the `?portfolio_id=` inventory filter, policy governed sets, `/context`). The excluded ids are returned in `PortfolioDetail.excluded_projects`. Requires Maintainer and licence level 2 or above.

        - The body is the complete desired exclusion set, never a diff. Absent or null `excluded_project_ids` is a 400; an explicit empty array clears every exclusion.
        - A newly added id is accepted only when a group rule of this portfolio currently matches it; a new id no rule reaches is a 400. An id already in the stored exclusion set is not re-checked and may stay even if no rule matches it any more; it excludes nothing and is removed by resubmitting the set without it, or by making the project an explicit member.
        - An id that is an explicit member is a 400 with its own distinct reason, stored or new: an explicit member is removed through `replacePortfolioProjects`, not excluded.
        - Adding an already-excluded project through `replacePortfolioProjects` drops its exclusion in the same transaction: an explicit member is never excluded.
        - Only the projects entering or leaving the resolved set are marked stale. Excluding a project also closes the issues of any policy it thereby loses, reported on the `project.update` event as `issues_closed`.
        - Excluding a project does not free a licence slot: a slot is consumed by deployment (see `LicenceStatus`), never by portfolio membership.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioExcludedProjectsRequest' }
      responses:
        '204': { description: 'Replaced; the projects entering or leaving the resolved set are marked stale and a project.update event is emitted for each, with any issues_closed count.' }
        '400': { description: 'Validation failed, naming the field and the offending ids: excluded_project_ids absent from the body (an explicit empty array is a valid full clear), an unrecognized top-level field, a malformed project id, more than 100 entries, a newly added id no group rule of this portfolio matches (an already-stored id is not re-checked), or an id that is an explicit member, stored or new (remove it instead).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '409':
          description: 'A submitted project was deleted concurrently while the request was in flight; nothing is written. Retry with a body that no longer names the vanished project.'
  /config/portfolios/{id}/policies:
    put:
      operationId: replacePortfolioPolicies
      summary: Replace the set of policies assigned to a portfolio (full set)
      description: |-
        The portfolio-side counterpart of the `portfolio_ids` half of `PUT /config/policies/{id}/assignments`. The body is the complete desired policy set: each policy newly present is assigned to the portfolio, each policy no longer present is unassigned, in one transaction. A project entering or leaving a policy's governed set is marked stale (a member the policy's scope filter excludes never moves); a project that loses a policy has that policy's issues closed, carried as `issues_closed`.

        - `{id}` must belong to the caller's own org (404 otherwise); every policy id must too (404 "policy not found", whole request rolled back).
        - Requires Maintainer level and licence level 2.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioPoliciesRequest' }
      responses:
        '204': { description: 'Replaced.' }
        '400': { description: 'Validation failed, naming the field: policy_ids absent from the body (an explicit empty array is a valid full clear), an unrecognized top-level field, a malformed policy id, or more than 100 entries.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Portfolio or a policy not found in the caller''s org.' }
        '409':
          description: 'The portfolio''s policy set changed concurrently while this request was in flight; nothing was written, the body is "the configuration changed concurrently, retry the request". Send the identical request again.'
  /config/portfolios/{id}/score-history:
    get:
      operationId: portfolioScoreHistory
      summary: Daily score trend for a portfolio
      description: |-
        A bounded date series in the org calendar, one point per day the portfolio has data for; the portfolio counterpart of `GET /config/policies/{id}/score-history`, same shape. Requires Member or above.

        - `{id}` must belong to the caller's own org (404 otherwise; unknown ids and ids belonging to a different org are indistinguishable).
        - `from`/`to` are calendar dates in the org calendar (`YYYY-MM-DD`, never a date-time). Both optional: `to` defaults to today, `from` to `to` minus 89 days. The range must not exceed 400 days and `from` must not be after `to` (400 otherwise).
        - Each `day` is the average, across the portfolio's current projects (explicit projects plus group-rule matches, as resolved now, not as they stood on that day), of each project's score at that day (its last completed run at or before that day's end), rounded half-up to one decimal. A project with no run at or before that day, or whose run then was unscorable (degraded collection, every policy withholding its score, or nothing evaluated), contributes nothing and is never averaged in as a 0. A day where no project has data is absent from `items` (a gap), never a zero-value point. Past days reshape when the portfolio's resolved project set changes.
        - If the range includes today, one freshly computed point for today is appended, against the current resolved set; if that computation fails, the point is omitted and every stored day is still returned.
        - `items` is ordered by `day` ascending; a gap is never backfilled or interpolated.
        - The current project set here spans every git instance of the org, unlike the `score_points` field of `GET /config/portfolios/{id}`, which is scoped to the caller's own login instance; in a multi-instance org the two can differ.
        - Days are the org's own calendar (its `timezone` setting), the same for every viewer; render `day` as a label, never through the browser clock. The response's `timezone` field names that calendar.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date lower bound (inclusive), read in the org calendar. Defaults to `to` minus 89 days.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Calendar date upper bound (inclusive), read in the org calendar. Defaults to the org today.' }
      responses:
        '200':
          description: The portfolio's daily score trend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScoreHistory' }
        '400': { description: 'Malformed portfolio id, a malformed from/to date, from after to, or a resolved range over 400 days.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail, or "trend unavailable" when the org''s timezone cannot be resolved.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }

  /config/portfolios/{id}/rules:
    put:
      operationId: replacePortfolioRules
      summary: "Full-set replace of a portfolio's group: path rules"
      description: |-
        A rule is a group path prefix (e.g. `team-a`), not a reference to a group object. A project belongs if its path equals the rule or sits under it: `team-a` matches `team-a/api` and `team-a/sub/web`, but never `team-alpha`. Matching is dynamic: projects that later appear under the path join automatically.

        - A path with a typo is accepted and simply matches nothing.
        - Rules track the path string, not the group's identity: if a group is renamed on the provider, the rule stops matching until it is updated to the new path.
        - The request body is the complete desired rule set, never a diff.
        - Only the projects entering or leaving the resolved set are marked stale; explicit projects are unaffected by this call, and a project a surviving rule matches before and after the replace is never staled.
        - Duplicate `group_path` values collapse rather than causing an error.
        - Requires Maintainer.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PortfolioRulesRequest' }
      responses:
        '204':
          description: 'Replaced; the projects entering or leaving the resolved set are marked stale and a project.update event is emitted for each.'
        '400': { description: 'Validation failed, naming the field: group_paths absent from the body (an explicit empty array is a valid full clear), an unrecognized top-level field, or an empty group_path.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, the org''s licence level is below 2 (portfolios require licence level 2 or above, see LicenceStatus.level), or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '409':
          description: 'A referenced id is unknown, belongs to a different org, or is not visible to the caller.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "config write failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown portfolio id, or a portfolio belonging to a different org.' }

  /catalog/repos:
    get:
      operationId: listCatalogRepos
      summary: List the org's registered catalog repositories (Member)
      description: |-
        Every repository this org has registered for continuous template cataloging, newest first.

        - `page` (default 1, minimum 1 - values below 1 are clamped up) and `per_page` (default 20, capped at 100) paginate the org's own repositories.
        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the org's registered catalog repositories.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogRepoList' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
    post:
      operationId: registerCatalogRepo
      summary: Register a repository for continuous template cataloging (OrgAdmin)
      description: |-
        Adds a repository to the org's catalog: the platform confirms the repository exists and is reachable, records it, and schedules a first scan in the background. Scanning never runs inline - this call returns before any file is read; poll `listCatalogSyncs` with the returned `sync_id` to follow the scan's own progress.

        - `project_id` identifies the repository on the org's connected git provider.
        - The org's connected git provider must support cataloging; a repository on an unsupported provider is rejected.
        - Registering the same repository twice for the same org is rejected.
        - Requires an authenticated session at OrgAdmin level.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogRepoRegisterRequest' }
      responses:
        '201':
          description: Registered; a first scan is scheduled in the background.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogRepoRegistered' }
        '400': { description: 'Malformed body: project_id missing or not a positive integer.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '409': { description: This repository is already registered for the org. }
        '422': { description: 'The repository could not be found or is not reachable with the org''s own provider credentials, the org''s connected provider does not support cataloging, or the org has no provider credentials configured yet.' }
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal), or the provider read needed to confirm the repository is rate-limited right now; the body tells the two apart. Retry shortly. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
  /catalog/repos/{repoId}:
    delete:
      operationId: deleteCatalogRepo
      summary: Remove a repository from the org's catalog (OrgAdmin)
      description: |-
        Removes the repository and everything cataloged from it (its templates, versions, and scan history); it stops being scanned. This never touches anything on the git provider itself.

        - `{repoId}` must belong to the caller's own org (404 otherwise).
        - Requires an authenticated session at OrgAdmin level.
      security:
        - sessionCookie: []
      parameters:
        - { name: repoId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Removed. }
        '400': { description: 'Malformed repoId (not a valid uuid); refused before any lookup.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown repository id, or a repository belonging to a different org.' }

  /catalog/repos/{repoId}/rescan:
    post:
      operationId: rescanCatalogRepo
      summary: Schedule a fresh scan of an already-registered repository (OrgAdmin)
      description: |-
        Schedules a new background scan for a repository already in the org's catalog, on top of whatever the nightly automatic scan already covers - useful right after pushing new templates upstream, without waiting for the next automatic pass.

        - `{repoId}` must belong to the caller's own org (404 otherwise).
        - The scan runs in the background; poll `listCatalogSyncs` with the returned `sync_id` for its outcome.
        - Requires an authenticated session at OrgAdmin level.
      security:
        - sessionCookie: []
      parameters:
        - { name: repoId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '202':
          description: A new scan is scheduled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogSyncScheduled' }
        '400': { description: 'Malformed repoId (not a valid uuid); refused before any lookup.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown repository id, or a repository belonging to a different org.' }
        '409': { description: A scan is already pending or running for this repository. }

  /catalog/repos/{repoId}/syncs:
    get:
      operationId: listCatalogSyncs
      summary: Scan history for one registered repository, newest first (Member)
      description: |-
        Every scan attempt recorded for this repository - the surface a client polls right after registering or rescanning to learn when a scan finished and what it found.

        - `{repoId}` scopes strictly to the caller's own org: an unknown id, or a repository belonging to a different org, answers an EMPTY page (`total: 0`), never an error (no existence leak).
        - `page` (default 1, minimum 1, values below 1 are clamped up) and `per_page` (default 20, capped at 100) paginate the repository's scan history; `total` reflects every match across every page.
        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: repoId, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of this repository's scan history (empty when repoId is unknown or belongs to a different org).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogSyncList' }
        '400': { description: Malformed repoId (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
  /catalog/templates:
    get:
      operationId: listCatalogTemplates
      summary: Browse every template cataloged across the org's registered repositories (Member)
      description: |-
        Org-wide, across every repository the org has registered for cataloging - not scoped to one repository. Each row carries its owning repository, every known version, and its labels.

        - `q` is a case-insensitive substring match against the template's path and description.
        - `labels` is a comma-separated list of label names; a template matches when it carries at least one of them. Omitted applies no label filter.
        - `deprecated` filters to deprecated-only (`true`) or active-only (`false`); omitted returns both.
        - `page` (default 1, minimum 1 - values below 1 are clamped up) and `per_page` (default 20, capped at 100) paginate the org's own templates; `total` in the response reflects every match under the current filters, not just the page size.
        - `sort`/`order`: closed, case-sensitive enums. `sort=recent&order=desc` (the default) orders by `updated_at` descending. `id` is always the final tiebreak so pagination stays stable.
        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Case-insensitive substring match against the template path and description.' }
        - { name: labels, in: query, required: false, schema: { type: string }, description: 'Comma-separated label names; a template matches when it carries at least one of them. At most 50 names are considered; any beyond that are ignored.' }
        - { name: deprecated, in: query, required: false, schema: { type: boolean }, description: 'true for deprecated only, false for active only; omitted returns both.' }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [name, recent], default: recent }, description: 'Sort key. name orders by the template path; recent orders by updated_at. Default recent. id is always the final tiebreak. Any other value is a 400 naming the field and the allowed set.' }
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: desc }, description: 'Sort direction, paired with sort. Default desc. Any other value is a 400 naming the field and the allowed set.' }
      responses:
        '200':
          description: One page of the org's cataloged templates.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogTemplateList' }
        '400': { description: 'sort or order is not one of its own closed, case-sensitive vocabulary values, naming the field and the allowed set.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
  /catalog/templates/{templateId}:
    get:
      operationId: getCatalogTemplateDetail
      summary: One cataloged template's full detail, including every known version (Member)
      description: |-
        `{templateId}` must belong to the caller's own org - an unknown id and a foreign-org id answer the same 404, never distinguishing the two.

        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: templateId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The template's full detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogTemplateDetail' }
        '400': { description: Malformed templateId (not a valid uuid). }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
        '404': { description: 'Unknown template id, or a template belonging to a different org - the same body, indistinguishable.' }

  /catalog/components:
    get:
      operationId: listCatalogComponents
      summary: The instance's CI/CD catalog resources as the org's own token sees them (Member)
      description: |-
        The CI/CD catalog resources of the org's git instance as the org's own token sees them, refreshed by sync; the source for the required-templates/required-components picker.

        - Scoped to the caller's own org, provider and instance.
        - `q` is a case-insensitive substring match against the resource's name and full path.
        - `page` (default 1, minimum 1, values below 1 are clamped up) and `per_page` (default 20, capped at 100) paginate the instance's catalog resources; `total` reflects every match under the current filters, not just the page size.
        - `sort`/`order`: closed, case-sensitive enums. `sort=name` (the default) orders by the resource's name; `sort=recent` orders by `seen_at` (when sync last saw it). `id` is always the final tiebreak so pagination stays stable.
        - `latest_version` is the empty string when the resource carries no known versions; `versions` is always an array, never null.
        - `namespace` is the group path the resource lives under, derived from its full path (the empty string for a resource with no group). `verification_level` is the provider's own value, verbatim; the empty string means the instance did not answer it, not that the resource is unverified. `version_components` carries the component names each known version publishes, in the same order as `versions` (`version_components[i].version == versions[i]`). `components` is the latest version's components (`version_components[0].components`), served flat; it is `[]` when the resource has no versions or the components were not answered.
        - Requires an authenticated session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: 'Case-insensitive substring match against the resource name and full path.' }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [name, recent], default: name }, description: 'Sort key. name orders by the resource name (the default); recent orders by seen_at. id is always the final tiebreak. Any other value is a 400 naming the field and the allowed set.' }
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc }, description: 'Sort direction, paired with sort. Default asc. Any other value is a 400 naming the field and the allowed set.' }
        - name: scope
          in: query
          required: false
          schema: { type: string, enum: [all, mine, official, gitlab_maintained], default: all }
          description: 'Closed, case-sensitive enum narrowing which catalog resources are listed. `all` (the default) is every resource the org token saw in this instance''s catalog. `mine` is every resource whose `namespace` is, or sits under, one of the org''s own synced group paths on this instance (segment-anchored prefix, as for portfolio group rules: `acme` matches `acme/platform/x`, never `acme-other/x`); it is the org token''s reach, not the calling member''s. `official` is every resource whose project on this instance a Maintainer marked as an official component repo (`PUT /projects/{id}/official-component`): the org''s own vocabulary, independent of the provider''s. `gitlab_maintained` is every resource whose `verification_level` is `GITLAB_MAINTAINED` or `GITLAB_PARTNER_MAINTAINED`; `VERIFIED_CREATOR_MAINTAINED`, `VERIFIED_CREATOR_SELF_MANAGED` and `UNVERIFIED` are outside it. On an instance that never answered the level, `gitlab_maintained` returns nothing. Any other value is a 400 naming the field and the allowed set.'
      responses:
        '200':
          description: One page of the instance's cataloged CI/CD components.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogComponentList' }
        '400': { description: 'sort, order or scope is not one of its own closed, case-sensitive vocabulary values, naming the field and the allowed set.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token (the fixed body "your access could not be verified with the provider: log in again, or your account may have been blocked"); log in again.'
        '500':
          description: 'An internal failure (for example a database error), never a client-caused rejection; the body is the fixed string "catalog request failed" with no diagnostic detail.'
        '503':
          description: 'The platform could not verify the caller''s session or determine the caller''s role (an infrastructure failure, not a refusal); retry later. During a provider outage a GET or HEAD may still be served from a role cached before the outage (by default up to 15 minutes past the cache TTL); a write is never served that way.'
  /catalog/labels:
    get:
      operationId: listCatalogLabels
      summary: The org's full label vocabulary (Member), paginated
      description: |-
        Every label known to the org, alphabetical: labels curated by an admin plus labels the catalog scan created automatically when it first saw them on a template.

        - `page` and `per_page` paginate the list; `total` counts every match across every page.
        - Requires a session at Member level or above.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
      responses:
        '200':
          description: One page of the org's labels.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogLabelList' }
        '400': { description: 'An unknown query parameter was supplied, naming it.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "catalog request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
    post:
      operationId: createCatalogLabel
      summary: Curate a new label for the org's catalog (OrgAdmin)
      description: |-
        `name` is trimmed of surrounding whitespace, then validated: 1 to 50 characters, letters, numbers, spaces, hyphens and underscores only.

        - Creating a label that already exists for the org is rejected.
        - Requires an authenticated session at OrgAdmin level.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogLabelCreateRequest' }
      responses:
        '201':
          description: The newly created label.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogLabel' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "catalog request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
        '409': { description: A label with this name already exists for the org. }
        '422': { description: 'The name is empty, over 50 characters, or contains a character outside letters, numbers, spaces, hyphens and underscores.' }

  /catalog/labels/{name}:
    put:
      operationId: updateCatalogLabel
      summary: Update a label's displayed flag (OrgAdmin)
      description: |-
        Updates the label's one mutable property, `displayed`: whether it is currently shown to browsers of the catalog. The label is updated in place, so every template association it carries survives.

        - `displayed` is required; an absent field answers 400 naming it.
        - Requires a session at OrgAdmin level.
      security:
        - sessionCookie: []
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogLabelUpdateRequest' }
      responses:
        '200':
          description: The updated label.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogLabel' }
        '400': { description: '`displayed` is absent from the body (naming it), or an unknown top-level field was supplied (naming it).' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "catalog request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
        '404': { description: No label with this name exists for the org. }
    delete:
      operationId: deleteCatalogLabel
      summary: Remove a label from the org's catalog (OrgAdmin)
      description: |-
        Removes the label and detaches it from every template that carried it; the templates themselves are untouched.

        - Requires an authenticated session at OrgAdmin level.
      security:
        - sessionCookie: []
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '204': { description: Removed. }
        '400':
          description: 'The path parameter is malformed.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "catalog request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
        '404': { description: No label with this name exists for the org. }

  /audit/analyses:
    get:
      operationId: listAuditAnalyses
      summary: 'Analysis history: pushed runs and asset-sync attempts, newest first (Maintainer)'
      description: |-
        The org's analysis history: two kinds of row interleaved newest first into one page. A `run` row is a pushed CLI analysis result (project, ref, pipeline and job ids, score, when it was received); a `sync` row is a scheduled or manual asset-sync attempt (instance, whether it was scheduled, its outcome, its error text when it failed). Each entry's `kind` names which one it is; only that kind's fields are populated.

        - Both kinds are windowed to the last 30 days (a run received, or a sync started, in the last 30 days), for the lists and the totals alike.
        - `runs_total` and `syncs_total` count each kind separately within that window; `total` is their sum.
        - Every push produces a `run` row (a successful re-check included); a re-check that never produces a push leaves no row here.
        - `run` rows are limited to the projects the caller can see, in the list and in `runs_total` alike; `sync` rows carry no project and are never filtered.
        - `page` and `per_page` paginate the merged page.
        - `project` applies to `run` rows only and excludes every `sync` row; `kind` narrows to one kind; combined filters intersect (`kind=sync&project=...` is empty). `from`/`to` narrow the range within the 30-day window: a `from` earlier than the window never widens it.
        - Requires a session at Maintainer level.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path. Applies to run rows only and excludes every sync row (they carry no project).' }
        - { name: kind, in: query, required: false, schema: { $ref: '#/components/schemas/AuditAnalysisKind' }, description: 'Narrows to one kind of row (run or sync); any other value answers 400.' }
        - { name: from, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 lower bound, intersected with the fixed 30-day window (never widens past it). A malformed value answers 400.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 upper bound (inclusive). A malformed value answers 400.' }
      responses:
        '200':
          description: One merged, newest-first page of the org's run and sync history.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditAnalysisList' }
        '400': { description: 'An undocumented query parameter was supplied (naming it), kind is not run/sync, or from/to is not a valid RFC3339 date-time.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "audit request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role or resolve the caller''s visible projects: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /audit/fixes:
    get:
      operationId: listAuditFixes
      summary: 'Fix history: every attempted fix across the org, newest first (Maintainer)'
      description: |-
        Every fix the org has attempted, newest first (`created_at` descending), limited to the projects the caller can see. Each row carries the project it targeted and the acting user's display name when a user triggered it (null for a system-triggered fix, or when that user was later deleted).

        - `page` and `per_page` paginate the list; `total` counts every visible match across every page.
        - `project`, `actor`, `status` and `from`/`to` filters combine; there is no fixed window, so `from`/`to` are unbounded when absent.
        - Requires a session at Maintainer level.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: project, in: query, required: false, schema: { type: string }, description: 'Exact provider project path.' }
        - { name: actor, in: query, required: false, schema: { type: string, format: uuid }, description: 'Exact-match filter against the platform user id; a system-triggered fix never matches. A malformed value answers 400.' }
        - { name: status, in: query, required: false, schema: { $ref: '#/components/schemas/FixStatus' }, description: 'Exact-match filter. Any other value answers 400.' }
        - { name: from, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 lower bound against created_at, unbounded when absent. A malformed value answers 400.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 upper bound (inclusive) against created_at. A malformed value answers 400.' }
      responses:
        '200':
          description: One page of the org's fix history.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditFixList' }
        '400': { description: 'An undocumented query parameter was supplied (naming it), actor is not a valid id, status is not a recognized value, or from/to is not a valid RFC3339 date-time.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "audit request failed".'
        '503':
          description: 'The platform could not verify the session, determine the caller''s role or resolve the caller''s visible projects: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /audit/users:
    get:
      operationId: listAuditUsers
      summary: 'User activity: every platform user of the org, most recently active first (Maintainer)'
      description: |-
        Every platform user of the org: name, email, provider identity, `last_login` and `last_activity`. Ordered most recently active first (`last_activity` descending, then `last_login` descending); a user with neither timestamp sorts last.

        - Not filtered by project visibility: every caller who can reach it sees the whole org.
        - `page` and `per_page` paginate the list; `total` counts every match across every page.
        - `actor` narrows to one user; `from`/`to` apply to `last_activity` (a user who has never been active never matches a bound).
        - Requires a session at Maintainer level.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: actor, in: query, required: false, schema: { type: string, format: uuid }, description: 'Exact-match filter against the platform user id. A malformed value answers 400.' }
        - { name: from, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 lower bound against last_activity. A malformed value answers 400.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 upper bound (inclusive) against last_activity. A malformed value answers 400.' }
      responses:
        '200':
          description: One page of the org's users, most recently active first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditUserList' }
        '400': { description: 'An undocumented query parameter was supplied (naming it), actor is not a valid id, or from/to is not a valid RFC3339 date-time.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "audit request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /audit/config:
    get:
      operationId: listAuditConfig
      summary: 'Config-change trail: every config mutation, newest first (Maintainer)'
      description: |-
        The org's config-change trail: at most one row per successful config-mutating write, newest first (`created_at` descending). Each row carries who acted (`actor_name`, null for a system write or a since-deleted user), what kind of entity changed (`entity_kind`, see `AuditConfigKind`), what happened (`action`: `created`, `updated`, `deleted` or `rotated`), and a short label captured at write time, which survives the entity's later deletion.

        - Recording is best-effort, after the write itself: a row can be lost when the platform fails right after the write, with no retry or backfill.
        - Windowed to the last 30 days, for the list and `total` alike.
        - `page` and `per_page` paginate the window; `total` counts every match within it.
        - `entity_kind`, `action`, `actor` and `from`/`to` filters combine; a system write never matches an `actor` filter; `from`/`to` stay within the 30-day window (a `from` earlier than the window never widens it).
        - Requires a session at Maintainer level or above. The trail never stores secret content: `entity_label`, `action` and `actor_name` are its only facts.
      security:
        - sessionCookie: []
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 }, description: 'Minimum 1; values below 1 are clamped up to 1.' }
        - { name: per_page, in: query, required: false, schema: { type: integer, default: 20 }, description: 'Capped at 100; values above 100 are clamped down.' }
        - { name: entity_kind, in: query, required: false, schema: { $ref: '#/components/schemas/AuditConfigKind' }, description: 'Exact-match filter. Any other value answers 400.' }
        - { name: action, in: query, required: false, schema: { $ref: '#/components/schemas/AuditConfigAction' }, description: 'Exact-match filter. Any other value answers 400.' }
        - { name: actor, in: query, required: false, schema: { type: string, format: uuid }, description: 'Exact-match filter against the platform user id. A malformed value answers 400.' }
        - { name: from, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 lower bound, intersected with the fixed 30-day window (never widens past it). A malformed value answers 400.' }
        - { name: to, in: query, required: false, schema: { type: string, format: date-time }, description: 'RFC3339 upper bound (inclusive). A malformed value answers 400.' }
      responses:
        '200':
          description: One page of the org's config-change trail, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditConfigList' }
        '400': { description: 'An undocumented query parameter was supplied (naming it), entity_kind/action is not a recognized value, actor is not a valid id, or from/to is not a valid RFC3339 date-time.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Maintainer, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "audit request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /reports/smtp-settings:
    get:
      operationId: getReportsSmtpSettings
      summary: The org's SMTP settings for the scheduled report (OrgAdmin)
      description: |-
        Returns the org's outbound-mail configuration used to send the scheduled report. `password_set` reports only whether a password is configured; the password itself is never returned by this or any other endpoint.

        - Requires a session at OrgAdmin level, on both the read and the write, because this surface holds a credential.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's SMTP settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SmtpSettings' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
    put:
      operationId: updateReportsSmtpSettings
      summary: 'Replace the org''s SMTP settings, tested before storing (OrgAdmin)'
      description: |-
        Replaces the org's SMTP configuration whole. Before anything is stored, the submitted settings are tested with a real connection to the mail server: connect, negotiate TLS per `tls_mode`, and authenticate when `username` is set. A failed test stores nothing and answers 400 naming the stage that failed (connect, TLS negotiation or authentication). On success the settings are stored and echoed back with `password_set: true`, never the password itself.

        The test never sends an email: a successful save proves the settings can connect and authenticate, not deliverability. The first real message an org sees is the next scheduled report.

        `password` is required on every write: this endpoint never partially updates a stored configuration, so changing only the host still requires resubmitting the password. `username` and `password` may both be omitted together for an unauthenticated relay, whose test skips authentication.

        - Requires a session at OrgAdmin level.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSmtpSettingsRequest' }
      responses:
        '200':
          description: Tested and stored. The stored SMTP settings, `password_set` true when a password was submitted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SmtpSettings' }
        '400': { description: 'Malformed body, an invalid field (host/port/from_address/tls_mode), or the test-send failed (message names the stage: connect, TLS, or authentication). Nothing is stored.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /reports/export:
    post:
      operationId: createReportExport
      summary: 'Request an on-demand report export: returns immediately, generates asynchronously (Member)'
      description: |-
        Starts generating a compliance evidence report (a zip containing a PDF and a CSV) for one scope: `organization`, `project`, `portfolio` or `policy`. The call never waits for generation: it creates a `pending` export and returns its id immediately. Learn the outcome by polling `getReportExport` with that id (status, then the bytes once ready), or by listening on `streamEvents` for the `report.export` event published to the requester when the export becomes `ready` or `failed`. The event is best-effort: a client that was not connected when it fired must still poll.

        Only the Member role is checked here; `scope_id` is not checked against the caller's visibility at request time. The report is assembled at generation time against the requesting user's own visible projects, so a scope the requester cannot see yields a `failed` export, never wider content.

        `scope_id` is required for `project`, `portfolio` and `policy` scope and must be omitted for `organization` scope (the org is implied by the session).

        The score header rendered inside the PDF and CSV (`score_letter`, `score_points`) is the same live figure the score-history endpoints serve for a policy (its governed projects) or a portfolio, and the same org-wide average `GET /overview` serves for `organization` scope; it reads `unscored` when nothing has ever been scored. `score_points` in the header is rounded to an integer and can differ by up to 0.5 from the one-decimal value those endpoints return. In an org with several connected git instances the header may cover a different project set than the report's own sections.

        A generated export is downloadable for 7 days from creation; see `getReportExport` for the retention and download-access rule.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateReportExportRequest' }
      responses:
        '201':
          description: The export request was accepted; generation runs asynchronously. Poll `getReportExport` with the returned id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportExport' }
        '400': { description: 'Malformed body: an unrecognized scope, a missing scope_id for a non-organization scope, or a scope_id supplied for organization scope.' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
  /reports/export/{id}:
    get:
      operationId: getReportExport
      summary: Poll an export's status, or download it once ready (requester only)
      description: |-
        Answers `202` while the export is `pending` (a JSON status body with a `Retry-After` hint: poll again) and `200` once it is terminal: `ready` returns the zip itself (`Content-Type: application/zip`), `failed` returns a JSON body naming the reason (`application/json`), never partial bytes. Tell "keep waiting" from "finished" by status code alone, then use the Content-Type of a `200` to tell success from failure.

        `Retry-After` (delta-seconds) is a fixed hint, not an estimate of the remaining work.

        Instead of a polling loop, a client may listen on `streamEvents` for the `report.export` event (`ready` or `failed` only, never `pending`) and then make one final GET here. The event is at-most-once and not replayed: this endpoint remains the authoritative source of the outcome, so a client that never sees the event must still poll.

        Only the user who requested the export may read its status or download it. An id belonging to another user answers `404`, the same as an unknown id.

        A generated export is downloadable for 7 days from creation; after that it answers `404`, the same as an unknown id.
      security:
        - sessionCookie: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '202':
          description: 'Still generating (`pending`). JSON status body; carries a `Retry-After` hint.'
          headers:
            Retry-After: { schema: { type: integer }, description: 'Seconds until a client should poll again - a fixed hint, not a completion estimate.' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportExport' }
        '200':
          description: 'Terminal: the ready export as a zip archive, or the failed reason as JSON (never partial bytes); the Content-Type tells them apart.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportExport' }
            application/zip:
              schema: { type: string, format: binary }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below Member, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
        '404': { description: 'Unknown id, an id belonging to a different org or requested by a different user, or an expired export.' }

  /reports/schedule:
    get:
      operationId: getReportSchedule
      summary: The org's scheduled report configuration and delivery status (OrgAdmin)
      description: |-
        Returns the org's scheduled report: its cron expression, recipient list, paused flag, who last saved it, and the outcome of its last run. An org that has never saved a schedule reads an inert default (`cron: ""`, `recipients: []`, `paused: true`, `last_run_status: ""`, `updated_by: ""`) rather than a `404`. In that case `next_run_at` is omitted from the body entirely, never present as `null`: check for the key's absence.

        `timezone` is echoed from the org's settings (`GET /settings/org`): the IANA name the cron is interpreted against. `next_run_at` is computed from `cron` and `timezone` and may already be in the past, which signals an overdue schedule.

        The scheduled report is org-scoped, not user-scoped: when it runs it includes every project the org's connected git instance reports, with no per-user visibility filter. For that reason only OrgAdmin may read or configure it.

        - Requires a session at OrgAdmin level.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The caller's org's scheduled report configuration and last-run status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportSchedule' }
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
    put:
      operationId: updateReportSchedule
      summary: 'Replace the org''s scheduled report configuration, validated at save (OrgAdmin)'
      description: |-
        Replaces the org's schedule whole: `cron`, `recipients` and `paused` are never updated independently. `last_run_at`, `last_run_status` and `last_run_error` are untouched, so reconfiguring does not erase the delivery history. `updated_by` is overwritten on every call and always names the most recent caller; it is a display field only.

        Validated at save, never when the schedule fires: `cron` must parse (standard 5-field syntax) or the call answers `400` and stores nothing. `cron` may be empty only while `paused` is `true`; `paused: false` requires a parseable `cron` and at least one recipient. Every entry in `recipients` must look like an email address.

        Setting `paused: true` takes effect immediately, even for a run already queued: the run is skipped and no `last_run_*` entry is recorded for it.

        `last_run_status: "success"` means the configured mail relay accepted the message for every recipient; it is not proof any inbox received it. A relay rejecting a recipient immediately (an unknown mailbox, a full quota, a policy rejection) is reported in `last_run_error`, naming the recipient(s) and why, never the credential; if even one recipient is rejected, the whole run is recorded `failed` and the message is sent to no one.

        - Requires a session at OrgAdmin level (see `getReportSchedule` for why).
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateReportScheduleRequest' }
      responses:
        '200':
          description: 'Validated and stored: the stored schedule, with the last-run fields unchanged, `updated_by` set to the caller, `timezone` echoed from the org setting and `next_run_at` recomputed.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportSchedule' }
        '400':
          description: 'Malformed body, an unrecognized top-level field, `paused` absent (named in the body, never defaulted), an unparseable cron, a recipient that does not look like an email address, or `paused: false` with an empty cron or no recipients. Nothing is stored.'
        '401': { description: No live session. }
        '403':
          description: 'The caller is below OrgAdmin, or the provider refused the caller''s token; log in again.'
        '500':
          description: 'An internal failure, never a client-caused rejection; the body is the fixed string "reports request failed".'
        '503':
          description: 'The platform could not verify the session or determine the caller''s role: an infrastructure failure, not a rejection. During a provider outage a GET or HEAD may still be answered from a bounded role cache (default 15 minutes past its TTL); a write is never served that way.'
components:
  securitySchemes:
    ciOIDC:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |-
        Native CI OIDC id_token (GitLab `id_tokens` / GitHub `id-token: write`), sent as a bearer token.

        - Verified per provider and instance: JWKS signature, audience, accepted issuer, and project identity claims.
        - Multiple identity providers are supported.
        - The platform enforces its own maximum token age (`PLUMBER_OIDC_MAX_TOKEN_AGE`, default 3600 s, measured from `iat`) and a 60 s clock-skew leeway.
    sessionCookie:
      type: apiKey
      in: cookie
      name: plumber_session
      description: |-
        Browser session cookie, obtained by sending the user through `startLogin` and letting the browser complete `completeLogin`. Role-gated endpoints additionally require a minimum role; the settings endpoints require OrgAdmin.
    bearerApiToken:
      type: http
      scheme: bearer
      description: |-
        A per-user platform API token (`plmb_...`, minted by `createMyApiToken`). Accepted only by the MCP endpoint `POST /api/v1/mcp` (Model Context Protocol, Streamable HTTP transport, stateless, JSON responses), never by any REST operation in this document.
  schemas:
    Push:
      type: object
      required: [schema_version, provider, instance, project, ref, pipeline, results]
      properties:
        schema_version: { type: integer, minimum: 1, description: 'Ingestion-contract version (>=1). Newer accepted (forward-tolerant).' }
        provider: { type: string, description: 'Provider name, e.g. gitlab or github. Informational: the OIDC claims are authoritative for identity.' }
        instance: { type: string, description: 'Provider instance host, e.g. gitlab.com.' }
        project:
          type: object
          properties:
            path: { type: string }
            id: { type: string, description: 'Stable provider id (survives renames).' }
        ref:
          type: object
          properties:
            branch: { type: string }
            tag: { type: string }
            sha: { type: string }
        pipeline:
          type: object
          description: 'Drives the idempotency guard (job_id) and ordering guard (id + timestamps).'
          properties:
            id: { type: string }
            job_id: { type: string }
        cli:
          type: object
          description: |-
            The CLI and component versions that produced this push. On every accepted push the platform reads component_version, falling back to version when component_version is absent, and refreshes the pushed project's stored component_ref from it. A value that is not shaped like a version (`^v?\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$`) is ignored. Values are normalized to the "v"-prefixed spelling before comparing or storing: a value naming the version the stored ref already names writes nothing; a different version is stored "v"-prefixed, but only when the stored ref is itself version-like or empty. A stored branch, `~latest` or commit-sha pin is never refreshed from a push. A refresh failure never fails the push. See ProjectUpdateEvent's component_ref/component_outdated for the SSE side of this refresh.
          properties:
            version: { type: string, description: "The pushing CLI binary's own version; used for the component_ref refresh when component_version is absent." }
            component_version: { type: string, description: 'The pinned CI component''s own version, when the CLI populates it separately from its own binary version; read first for the component_ref refresh. Absent on every CLI build up to and including v0.6.0.' }
        collection: { $ref: '#/components/schemas/CollectionMeta' }
        evaluation: { $ref: '#/components/schemas/PushEvaluation' }
        results:
          type: array
          description: 'One entry per policy, never a merged policy. Normally non-empty: an empty array is rejected (422) unless the push carries the nothing-evaluated marker (see evaluation), the one shape that makes an empty result list a legitimate run.'
          items: { $ref: '#/components/schemas/PolicyResult' }
        bom:
          description: 'The pipeline bill of materials this run computed. Optional and additive: schema_version does not change, and a push without it leaves the project''s stored dependency edges untouched rather than emptying them. Only a default-branch run''s bill is projected into the graph. See PushBOM for the bounds.'
          allOf: [ { $ref: '#/components/schemas/PushBOM' } ]
    PushEvaluation:
      type: object
      required: [nothing_evaluated]
      description: |-
        The nothing-evaluated marker. Optional and additive: absent means this push evaluated something.

        A CLI that resolved zero policies from the platform, or whose every resolved policy tree failed to apply, pushes with this marker and the platform records the run as not evaluable: the run is stored and becomes the project's current, freshest run, it carries no policy result and no control result, no score is computed or displayed for it (the project keeps its last scored grade, flagged by `newest_run_unscorable`), and the gate answers `evaluated: false` with the reason echoed, so it never blocks.

        The marker can only make the platform record less: it yields a run with no results, never creates, closes or scores anything, and is refused (422) when it rides a non-empty `results` array.
      properties:
        nothing_evaluated:
          type: boolean
          description: 'True means this run evaluated nothing at all. False (or the whole object absent) is an ordinary push. The platform reads this field alone as the marker: reason below only qualifies it.'
        reason:
          type: string
          enum: [no_policy, policies_not_applicable]
          description: 'Why nothing was evaluated. `no_policy`: the platform resolved no policy for this project, so the CLI had nothing to run. `policies_not_applicable`: policies were resolved but none of their trees applied to this project. A closed set, additive later: an unrecognized value is tolerated rather than rejected, recorded as no reason at all, and answered with the generic `nothing_evaluated` gate reason.'
    PolicyResult:
      type: object
      required: [policy, findings]
      properties:
        policy: { type: string, description: 'Policy display name. [Plumber default] is report-only, never persisted. Kept alongside policy_id for display and for one contract version of backward tolerance.' }
        policy_id:
          type: string
          format: uuid
          description: 'The policy uuid the CLI evaluated under. Optional for one contract version: a push carrying only policy (the name) is still accepted; the platform resolves the name against the org''s policies at ingest (unique match only) and reports an unresolvable name in the push response''s unresolved_policies. A policy_id that does not name a policy of the pushing project''s own org is treated exactly like an unresolvable name.'
        effective_config:
          type: object
          description: 'What the CLI ran for this policy, as the flat per-provider controls map: top-level keys are control names in the config file''s own camelCase spelling (branchMustBeProtected, ...), values are that control''s config object. No provider/version/effectivePolicy nesting: any other shape is read as an empty configuration and leaves every issue without parameters.'
        findings:
          type: array
          items: { $ref: '#/components/schemas/Finding' }
        score:
          allOf:
            - $ref: '#/components/schemas/Score'
          description: |-
            The CLI's own score for this policy, stored as pushed. Optional: a CLI that evaluated no control of this policy omits it; an absent score, or one with an empty letter, is stored as no result, never a grade.
        score_context:
          allOf:
            - $ref: '#/components/schemas/ScoreContext'
          description: 'Additive (CLI v0.6.0 and later, `scoring-v4` only): the attack paths the CLI priced for this policy, a plain-language situation paragraph and the best fix, next to the score they explain. Stored as checked, the known fields only (an unknown field is accepted but not kept beside the score), and served by `getProjectScoreContext`. A push whose section exceeds its bounds (512 KiB, at most 256 paths, sentences of at most 1 KiB, a situation of at most 4 KiB) or whose sections exceed 4 MiB in total is refused with 422.'
    Finding:
      type: object
      required: [control, status]
      properties:
        control: { type: string }
        version: { type: string, description: 'Control version; weights and points live in the versioned control definition.' }
        requirement: { type: string }
        status:
          type: string
          enum: [pass, fail, not_evaluable]
          description: |-
            Finding outcome (`pass | fail | not_evaluable`).

            - `not_evaluable` is explicit for degraded or silent controls, never omitted: it contributes zero points, is excluded from the denominator, and never closes its issue.
            - There is no per-control fail-mode setting: degraded is single and not configurable.
            - Enforcement (`block | report`) lives on the policy, not the finding.
        data:
          type: object
          description: 'Control-specific payload plus canonical fields (code/severity/message/job/file/line/url). Unknown keys are kept raw rather than dropped.'
        dismissed:
          type: boolean
          description: |-
            Optional, absent means false. The CLI sets this when it matched this finding's identity against an entry of ProjectContext.dismissed_issues. It is not a fourth status value: `pass | fail | not_evaluable` stays the finding-status vocabulary, and dismissed is an orthogonal marker on a (typically fail) finding.

            It is a claim about what /context told the CLI, never a write instruction: the platform's own stored issue status decides whether the underlying issue is Dismissed, and a marker on a finding whose issue is not (or no longer) Dismissed is treated as an ordinary fail.

            A dismissed-marked finding stays visible everywhere on the platform, so a dismissal is never mistaken for a fix. Since CLI v0.4.59 the CLI excludes a marked finding from its own pushed score, which the platform stores as pushed. Older CLIs never send this field.
    Score:
      type: object
      required: [letter, points]
      description: |-
        A grade A-E plus points. On a push it is the CLI's own score, which the platform stores as pushed: `points` is the formula's signed raw points (no floor), `final_points` the final points (floored at 0, capped at 30 while a Critical path or code exists), `profile_id` the formula that produced them. On every response the platform builds (`global_score`, the SSE `project.update` score): `points` is the final points, the displayed score, and `final_points` and `profile_id` are absent.
      properties:
        letter:
          type: string
          description: 'Grade A-E: the band of the final points, so the Critical malus is included.'
          examples: [A, B, C, D, E]
        points: { type: integer, description: 'On a push: signed raw points, negative values kept, between -100000 and 100. On a response: the final points, the displayed score.' }
        final_points: { type: integer, description: 'Push only: the formula''s final points, 0 to 100. Absent from responses, and from a CLI older than v0.4.58, whose points are then read as final points (30 for an E above 30, never below 0).' }
        profile_id: { type: string, pattern: '^[a-z0-9][a-z0-9.-]{0,63}$', description: 'Push only, additive (CLI v0.6.0 and later): the scoring formula that produced this score, `scoring-v4` for the contextual score. Absent under `scoring-v3` and from older CLIs; the platform then records `scoring-v3`.' }
    ScoreContext:
      type: object
      required: [paths, situation]
      description: 'The attack-path picture behind one policy score (contextual score, `scoring-v4`).'
      properties:
        paths:
          type: array
          items: { $ref: '#/components/schemas/ScorePath' }
          description: 'Priced attack paths, worst first. The CLI sends at most 200.'
        situation: { type: string, description: 'Three labelled facts as "Label: value." sentences: Repository (visibility), Default branch (name and protection), Workflows or Pipeline (job count). No best-fix text.' }
        best_fix: { $ref: '#/components/schemas/ScoreBestFix' }
    ScorePath:
      type: object
      required: [id, tier, state, entry_kind, jobs, reach_kind, sentence, finding_hashes]
      properties:
        id: { type: string, description: 'Deterministic path id (a hash of the path key).' }
        tier: { type: string, description: 'critical, high, medium or low.' }
        state: { type: string, description: 'proven, or unverified when a fact on the path could not be decided.' }
        entry_kind:
          type: string
          description: 'How an attacker gets in: fork_pr, pr_target, untrusted_expression, mutable_dependency, unprotected_push or poisoned_cache. Open vocabulary (bounded by character class and length, not a closed set): a later CLI may add kinds and the platform accepts them without a contract change.'
        jobs: { type: array, items: { type: string }, description: 'The entry job first, then the jobs the attack reaches.' }
        reach_kind:
          type: string
          description: 'What the path reaches: impact:<kind> (for example impact:publishes, impact:deploys, impact:writes_repo, impact:signs_or_releases), secrets, token, execution or none. The STRONGEST single kind of the path''s reach, which may hold several (impact kinds rank above secrets/token/execution/none). Open vocabulary, like entry_kind.'
        modifiers:
          type: array
          items: { type: string }
          description: 'Tier modifiers applied: private_exposure, unresolvable, gate:<ISSUE code>, push_entry_cap, dependency_cap. Absent when none. Open vocabulary, like entry_kind.'
        sentence: { type: string, description: 'One plain-language sentence explaining the path. Render as text.' }
        finding_hashes: { type: array, items: { type: string }, description: 'The identity hashes of the findings that make up the path, anchors first, then gates.' }
    ScoreBestFix:
      type: object
      required: [anchor_hash, code, points_gained, new_letter, sentence]
      properties:
        anchor_hash: { type: string }
        code: { type: string, description: 'The ISSUE code of the finding to fix.' }
        job: { type: string }
        subject: { type: string, description: 'What to fix (an action, an image, an expression), as written in the workflow. Render as text.' }
        points_gained: { type: number, format: double, description: 'Points the fix recovers, one decimal.' }
        new_letter: { type: string, description: 'The letter after the fix.' }
        sentence: { type: string, description: 'One plain-language sentence. Render as text.' }
    ProjectScoreContext:
      type: object
      required: [items]
      properties:
        run_id: { type: string, format: uuid, description: 'The run served. Absent when the project has no scored run.' }
        items:
          type: array
          items: { $ref: '#/components/schemas/PolicyScoreContext' }
    PolicyScoreContext:
      type: object
      required: [policy, score_letter, score_points, raw_points, score_withheld, score_profile]
      properties:
        policy: { type: string, description: 'The policy name as pushed.' }
        policy_id: { type: string, format: uuid, description: 'Absent for the derived default policy.' }
        score_letter: { type: string, description: 'Grade A-E; empty when the score was withheld.' }
        score_points: { type: integer, description: 'Final points, the displayed score; 0 when withheld.' }
        raw_points: { type: integer, description: 'Signed raw points, history only.' }
        score_withheld: { type: boolean, description: 'True when the CLI evaluated nothing for this policy: no score, not a grade.' }
        score_profile: { type: string, description: 'The scoring formula that produced this result''s score: `scoring-v3`, `scoring-v4` (the contextual score), or a newer name from a later CLI.' }
        context: { $ref: '#/components/schemas/ScoreContext' }
    CollectionMeta:
      type: object
      description: 'Degradation signal for the data collection the CLI consumed.'
      properties:
        degraded: { type: boolean }
        snapshot_collected_at: { type: string, format: date-time, description: 'collected_at of the data collection snapshot the CLI consumed (a cached value, not a live read from the provider).' }
        missing_fields: { type: array, items: { type: string } }
    PushAccepted:
      type: object
      required: [project, freshness, global_score, recomputed, kind, unresolved_policies, gate]
      description: 'The push response. Every property is always present.'
      properties:
        project: { type: string }
        freshness: { $ref: '#/components/schemas/Freshness' }
        global_score:
          allOf: [ { $ref: '#/components/schemas/Score' } ]
          description: 'The run''s displayed score: the integer average of the policies'' final points, with the letter read from that average (one policy: that policy''s result exactly). Display and aggregation only, never used for enforcement. Since CLI v0.4.60 the CLI prints it as the platform''s global score in its verdict, the badge and the MR comment headline.'
        recomputed:
          type: boolean
          deprecated: true
          description: 'Deprecated, always false: the platform stores each policy''s score as the CLI pushed it and no longer recomputes it. Kept for clients that still read it.'
        kind:
          type: string
          enum: [created, replayed, out_of_order, re_check]
          description: |-
            How this push landed: `created` for a new run; `replayed` for an idempotent duplicate (same job id); `out_of_order` for an older pipeline landing after a newer one was already accepted; `re_check` for the platform's own re-check landing, whose pipeline and job ids match the re-check the platform itself triggered: the existing run for that pipeline is updated in place, issues are created if absent, freshness goes from running to fresh, and the displayed run is left untouched. Stable across retries of the same job: a push that landed as `out_of_order` answers `out_of_order` again every time that same job pushes again, and `gate.evaluated` stays false on every attempt; every other replay answers `replayed`.
        unresolved_policies:
          type: array
          items: { type: string }
          description: |-
            The pushed policy names (results[].policy) the platform could not resolve to a policy uuid. This never contains a policy_id: an unknown policy_id (or one naming a different org's policy) is discarded and falls through to name resolution as if absent, so only the name is echoed here. A results[].policy value that looks like a uuid but matches no policy of this org is flagged here too. Empty when every result resolved. An unresolved result is dropped from global_score, gate, and coverage, though its findings may still create or match an issue. A policy pushed more than once within one push collapses to its last occurrence; if it never resolved, it is named here exactly once.
        gate:
          allOf: [ { $ref: '#/components/schemas/Gate' } ]
          description: 'The blocking gate''s decision on this push. The CLI already produced every pass/fail in `results`; this block only reports which of the project''s policies the stored block|report enforcement turns into a blocking decision. `evaluated` is false for a push with no current-state tally: out-of-order (an older pipeline landing after a newer one), a terminal-state skip (the pushed project is unboarded, unboarding or provider_deleted), or a nothing-evaluated push; no blocking decision applies to any of them, and a block-mode policy with a min_points threshold is no exception.'
    Gate:
      type: object
      required: [evaluated, blocking]
      properties:
        evaluated:
          type: boolean
          description: 'False only for a push with no current-state tally (see PushAccepted.gate): out-of-order, a terminal-state skip, or a nothing-evaluated push (the run evaluated nothing, so the gate lets the pipeline through). True for a fresh push, an idempotent replay, and a re_check landing.'
        blocking:
          type: boolean
          description: 'True when at least one block-mode effective policy has a live (non-dismissed) failing finding from this push. A project with no explicit policy assignment (the report-only [Plumber default] fallback) never blocks.'
        policies:
          type: array
          items: { $ref: '#/components/schemas/GatePolicy' }
          description: 'Present when evaluated is true: every effective policy for this project (not only the failing ones), each with its own enforcement and live fail count. Absent (never an empty array) when evaluated is false.'
        reason:
          type: string
          enum: [out_of_order, unboarded, unboarding, provider_deleted, no_policy, policies_not_applicable, nothing_evaluated]
          description: 'Present only when evaluated is false, naming why no blocking decision applies to this push. `out_of_order`: an older pipeline landed after a newer one was already accepted. `unboarded`, `unboarding`, `provider_deleted`: a terminal-state skip: the project was in that state at push time, so the push was accepted and stored (2xx, run retained) but deliberately not evaluated as the project''s current state. `no_policy`, `policies_not_applicable`, `nothing_evaluated`: echo a nothing-evaluated push''s own marker (see PushEvaluation): the first two are the marker''s own reason handed back, the third answers an absent or unrecognized reason. A terminal-state skip takes precedence when a push is both.'
    GatePolicy:
      type: object
      required: [id, name, enforcement, blocking, live_fail_count]
      properties:
        id: { type: string, format: uuid, description: 'The policy uuid (matches Policy.id / ProjectContext.policies[].id).' }
        name: { type: string, description: 'Policy display name at push time.' }
        enforcement:
          type: string
          enum: [block, report]
          description: 'This policy''s own enforcement, the same value Policy.enforcement carries.'
        blocking:
          type: boolean
          description: 'When this policy''s min_points is unset: true when enforcement is block and live_fail_count > 0 for this policy alone. When min_points is set: true when enforcement is block and this push''s per-policy final points, as the CLI pushed them, are strictly below min_points; live_fail_count still reports the raw fail tally but no longer decides blocking alone. A result pushed without a score (or with an empty letter) while live_fail_count > 0 counts as below min_points. A min_points policy this push attributed no evaluated result to (no result, or one with no pass and no fail) never blocks.'
        live_fail_count:
          type: integer
          description: 'How many of the just-pushed fail findings are attributed to this policy and whose issue is not Dismissed. A finding whose policy never resolved (see unresolved_policies) is attributed to no policy and never counted here.'
    Policy:
      type: object
      required: [id, name, enforcement, requirements, control_groups]
      properties:
        id:
          type: string
          format: uuid
          description: 'The policy uuid, what the CLI keys pushed results on via policy_id. The derived [Plumber default] fallback carries the nil uuid (all zeros): it is not a real policy.'
        name: { type: string, description: 'Policy identifier/display name. [Plumber default] is the derived, report-only fallback: never persisted, not deletable.' }
        enforcement:
          type: string
          enum: [block, report]
          description: 'Enforcement: block or report. There is no fail_mode field: degraded controls are not configurable (see Finding.status).'
        min_points:
          type: integer
          nullable: true
          description: 'The per-policy enforcement threshold, on the same scale as score points (final points). When set, a block-mode policy blocks when its per-policy final points, as the CLI pushed them, are below this value (letters derive from points); when unset (null or absent), the gate applies the any-fail rule. A report-mode policy never blocks regardless of this value. Since CLI v0.4.60 the CLI renders this value in its per-policy verdict line; the run''s exit code is the push response''s gate verdict, never a local threshold.'
        requirements:
          type: array
          description: 'This policy''s own control tree: control groups, each with its controls and this policy''s own per-control configuration, ordered deterministically. Two policies may declare the same control_type, each with its own config: a runner evaluating this policy must read its entry here, never assume a control_type''s config is shared across policies. Empty when the policy has no stored requirements yet (always empty for the [Plumber default] fallback). Legacy key: byte-identical to control_groups below, kept while the CLI still decodes `requirements`.'
          items: { $ref: '#/components/schemas/PolicyControlGroup' }
        control_groups:
          type: array
          description: 'The same content as requirements above, byte-identical, under the current field name. Both fields are required and populated from the one resolved control tree; a reader may use either key.'
          items: { $ref: '#/components/schemas/PolicyControlGroup' }
    PolicyControlGroup:
      type: object
      required: [name, controls]
      description: 'One control group inside a Policy control tree (Policy.requirements / Policy.control_groups), carrying only what a runner''s evaluation needs. ControlGroupDetail is the configuration API''s shape of the same group, which also carries an id and description.'
      properties:
        name: { type: string }
        controls:
          type: array
          items: { $ref: '#/components/schemas/PolicyControl' }
    PolicyControl:
      type: object
      required: [control_type, config]
      description: 'One control instance inside a PolicyControlGroup.controls list. ControlDetail is the configuration API''s shape of the same control, which also carries id and version.'
      properties:
        control_type: { type: string }
        config:
          type: object
          description: 'This control''s configuration for this policy, served byte-for-byte as stored: never redacted, summarized, or shared across policies.'
          x-go-type: json.RawMessage
    ProjectContext:
      type: object
      required: [schema_version, project, policies, dismissed_issues, snapshot]
      properties:
        schema_version: { type: integer }
        project: { type: string, description: 'Resolved project path (matches the {project_path} route parameter).' }
        policies:
          type: array
          description: 'Resolved policy set, never empty (falls back to [Plumber default], report-only). Each entry carries its own full control tree (Policy.requirements / Policy.control_groups).'
          items: { $ref: '#/components/schemas/Policy' }
        dismissed_issues:
          type: array
          description: |-
            The project's issues currently in status Dismissed, served so the CLI can match its own findings' identities against them and mark (never omit) a match as dismissed; see Finding.dismissed for the pushed side. Always present: a plain array, `[]` when the project has no dismissed issues.

            Capped at 1000 entries: a project with more dismissed issues is served only the newest 1000 by status-change time, with no truncation flag.

            Versions never mix: recipe_version on each entry is served verbatim, and a CLI whose own identity-recipe version differs from an entry's must skip that entry rather than match across versions (an unmatched dismissal simply fails to suppress).

            A dismissal made after this context was fetched takes effect at the CLI's next run. Identity is branch-blind: one dismissal suppresses the same finding on every branch of this project; there is no per-branch dismissal scoping.
          items: { $ref: '#/components/schemas/DismissedIssue' }
        snapshot:
          type: object
          properties:
            collected_at: { type: string, format: date-time }
            last_attempt_at:
              type: string
              format: date-time
              description: 'When the platform last attempted a collection pass for this project, whatever that pass concluded; distinct from `collected_at`, which is when the last good data was collected. A pass that learned nothing keeps the last good data at its real age, and this field says the platform did try. Absent when no pass has ever run for the project.'
            last_attempt_outcome:
              type: string
              description: 'How the pass at `last_attempt_at` ended. `complete`: every fetch it attempted succeeded. `partial`: some fetches succeeded and some did not (a genuine degradation, or an endpoint answering an absence-shaped 404, as GitLab CE does for MR-approval endpoints), and the snapshot was written. `learned_nothing`: no fetch succeeded and no absence-shaped 404 was seen; the snapshot write was skipped. `outage`: no fetch succeeded and at least one endpoint answered an absence-shaped 404, which on a project the platform already knows means the git host is down; the write was skipped. Absent when no pass has ever run.'
            data:
              type: object
              description: 'The collected project settings: branch protection rules, MR approval rules, CI variable configuration. May include `merged_yaml`, the resolved CI configuration, with an optional authoritative override from the CLI.'
              properties:
                schema_version:
                  type: string
                  description: 'Version tag for this data payload''s shape (distinct from the top-level schema_version), so a reader can judge compatibility as the shape evolves. "2" is the current shape: it adds includes, branch_protection.branches, ci_config_path, and degraded_fields. Absent on snapshots collected before this field existed. A reader that understands "2" must not treat a "1" (or absent) snapshot''s missing degraded_fields as "nothing degraded".'
                branch_protection:
                  type: object
                  description: 'Present whenever the branch-protection fetch succeeds, even when the project has zero protected branches: an empty `protections` array is a genuine fact, never "could not be fetched" (that case leaves this whole key absent and adds "branch_protection" to degraded_fields). `branches` lists the existing branch names; `protections` lists the per-branch protection rules. Both are always present together as arrays (possibly empty) whenever this key is present.'
                  properties:
                    branches: { type: array, items: { type: string }, description: 'Every branch name the fetch found, protected or not, so a control can tell "this branch does not exist" from "this branch exists and is unprotected".' }
                    protections: { type: array, items: { type: object }, description: 'One entry per protected branch (GitLab''s branch protection shape). An empty array with `branch_protection` present means zero protections exist: a real finding, not an absence.' }
                mr_approvals: { type: object }
                variables:
                  type: object
                  description: 'CI/CD variable configuration (name, type, environment, protected/masked/hidden flags, and the scope it is defined at: group, project, or instance); values are never included. Every per-item flag is significant: the CLI expands $VAR in image references from the job''s own environment, and the flags decide whether one job''s value can stand in for another''s.'
                merged_yaml: { type: string, nullable: true }
                raw_config:
                  type: string
                  description: 'The raw, un-merged root CI config file exactly as fetched (at ci_config_path on the default branch). Present whenever the file fetch succeeded, including when the merge step failed or reported INVALID, so a reader can tell "no CI file" from "file exists but the merge is absent or broken". Capped at 1 MiB: past the cap this key is absent and "raw_config" appears in degraded_fields. When ci_config_path names the instance''s central file (external CI configuration mode), this is that file, read at its own ref. When ci_config_path names any other file outside the project, the platform does not read it: this key and merged_yaml are absent and both appear in degraded_fields.'
                merged_yaml_status:
                  type: string
                  description: 'GitLab''s own merge status for merged_yaml, verbatim ("VALID" or "INVALID"). Absent when the merge response itself is absent: project archived (see project_details.archived) or the merge fetch failed (then "merged_yaml" appears in degraded_fields). With this key, all four causes of merged_yaml absence are distinguishable.'
                ci_errors:
                  type: array
                  items: { type: string }
                  description: 'GitLab''s CI lint/merge errors for the config at collection time, verbatim. Omitted when empty; typically non-empty exactly when merged_yaml_status is "INVALID".'
                project_details:
                  type: object
                  required: [default_branch, archived, path_with_namespace]
                  description: 'Core project facts from the same project lookup that resolves ci_config_path. Present whenever that lookup succeeds (archived projects included); when it fails, "project_details" appears in degraded_fields. The eight merge-settings properties (merge_method through remove_source_branch_after_merge) are optional: always present on fresh collections, absent on older stored snapshots until the next refresh.'
                  properties:
                    default_branch: { type: string, description: 'The project''s default branch at collection time.' }
                    archived: { type: boolean, description: 'Whether the project is archived. An archived project still gets this section; its merged_yaml is then empty, without a degraded_fields entry.' }
                    path_with_namespace: { type: string, description: 'The project''s full path (group/project) as GitLab reports it.' }
                    merge_method: { type: string, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    squash_option: { type: string, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    merge_pipelines_enabled: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    merge_trains_enabled: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    allow_merge_on_skipped_pipeline: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    resolve_outdated_diff_discussions: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    printing_merge_request_link_enabled: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                    remove_source_branch_after_merge: { type: boolean, description: 'Optional: always present on fresh collections; absent on older stored snapshots until the next refresh.' }
                security_policy_project:
                  type: object
                  required: [known]
                  description: 'The GitLab security-policy-project linkage. known=true with id/full_path: linked. known=true alone: read authoritatively, nothing linked. known=false: not read authoritatively (auth failure, a null project, or the field unavailable on this instance), paired with "security_policy_project" in degraded_fields; treat as not_evaluable, never as a pass or a fail. id is omitted when zero.'
                  properties:
                    known: { type: boolean }
                    id: { type: integer, description: 'Numeric project id of the linked security policy project; present only when known and non-zero.' }
                    full_path: { type: string, description: 'Its namespace/path; present only when known and linked.' }
                includes:
                  type: array
                  items: { type: object }
                  description: |-
                    The include entries of the merged CI configuration, carried as-is in the CLI's own shape (location/raw/blob/contextProject/type/extra) so the CLI consumes them natively. Present only alongside a successfully resolved merged_yaml, same as resolution_anchor. Lets the CLI attribute a job to the component or template it came from instead of reading it as hardcoded.

                    Each entry may carry `ref_exists_as_tag` and `ref_exists_as_branch` (nullable booleans, always both present or both absent; absent or null means not determined, never a fabricated false) and, on component includes, `source_catalog` (the catalog resource, with its versions and their per-version component lists; omitted, with "source_catalog" in degraded_fields, when the lookup failed; present but empty, `{"versions": []}`, when the project is not a catalog resource, which is a determined answer). `source_catalog` is served once per target within a snapshot: when several entries share the same target, only the first carries it. Join by target; an omitted key on a later entry sharing an already-served target is not a failed lookup.

                    Each entry may also carry `jobs` (array of strings) and `jobs_known` (boolean): which jobs this include contributed to the merged pipeline. `jobs_known` is the only discriminator: true with an empty or absent `jobs` is a determined empty answer (a nested include, or one contributing only variables); false or absent means the attribution could not be established, paired with "includes_jobs" in degraded_fields. `jobs` is a set: its order is not meaningful. Older snapshots may still carry `latest_version` and `ref_is_ambiguous`; ignore them.
                ci_config_path:
                  type: string
                  description: 'The project''s configured CI config file path (defaults to ".gitlab-ci.yml"). Present whenever the project-details fetch succeeds, whether or not merged_yaml itself resolved (an archived or INVALID project still has a known ci_config_path). resolution_anchor is computed against this same path, so the CLI can digest against it too.'
                resolution_anchor:
                  type: object
                  description: 'What this snapshot''s merged_yaml was resolved against (branch-aware CI config resolution keyed by content digest). The ref/sha pair is always present; config_digest/digest_version are absent when the digest could not be computed (a fetch failure while walking the config''s local includes).'
                  required: [ref, sha]
                  properties:
                    ref: { type: string, description: 'The branch resolved (the default branch at collection time).' }
                    sha: { type: string, description: 'The commit sha merged_yaml was resolved at.' }
                    config_digest: { type: string, nullable: true, description: 'The CI config content digest computed over the root CI config file and its local includes at sha. Absent when it could not be computed.' }
                    digest_version: { type: string, nullable: true, description: 'The digest algorithm version paired with config_digest; absent together with it.' }
                degraded_fields:
                  type: array
                  items: { type: string, enum: [branch_protection, mr_approvals, variables, merged_yaml, project_details, raw_config, source_catalog, security_policy_project, includes_jobs] }
                  description: 'Stable identifiers of every field whose fetch failed in this collection. A name here means "could not be fetched, treat as not_evaluable"; a field absent from the payload and from this list is genuinely empty (a control may fail against it). One identifier can cover several fetches (mr_approvals: rules + settings; variables: group + project + instance scopes); each appears at most once. An archived project or an INVALID user CI config does not add "merged_yaml" here: those are facts, not fetch failures. `raw_config`: the file was fetched but exceeded 1 MiB, or ci_config_path names a file outside the project that could not be read or that the platform does not read (anything but the instance''s central file). `source_catalog`: at least one component include''s catalog lookup failed (never when a lookup determined "not a catalog resource"). `security_policy_project`: the linkage read was not authoritative (known=false). `includes_jobs`: at least one include''s job attribution could not be established, or the derivation was capped or failed.'
    DismissedIssue:
      type: object
      description: 'One entry of ProjectContext.dismissed_issues: the match key the CLI uses to mark its own findings as dismissed. See that field for the cap, ordering, version-skip, and branch-blindness rules.'
      required: [identity_hash, recipe_version, control_type]
      properties:
        identity_hash:
          type: string
          description: 'The dismissed issue''s identity hash, computed under the shared identity recipe at recipe_version. Together with recipe_version this is the CLI''s match key; control_type is not part of the match, it only lets the CLI pre-filter cheaply.'
        recipe_version:
          type: integer
          description: 'The identity-recipe version identity_hash was computed under (currently 6), served verbatim from the stored identity. Versions never mix: a CLI whose own recipe version differs from this value must skip this entry rather than match across versions.'
        control_type: { type: string, description: 'Rides along so the CLI can cheaply pre-filter its own findings by control before hashing each one against every served dismissed identity. Not itself part of the match key.' }
    ResolveConfigRequest:
      type: object
      description: 'Body of resolveProjectConfig: the CLI''s own view of what it is analyzing, resolved by the platform.'
      required: [sha]
      properties:
        sha: { type: string, description: 'The commit sha the CLI is analyzing (its own checkout).' }
        config_digest:
          type: string
          nullable: true
          description: "The CLI's own config digest, computed from its job checkout: the cache key candidate. The platform's own recomputation over the same sha is authoritative; a mismatch is a diagnostic only, never an error. May be absent when the CLI's own computation aborted (more than 50 files traversed, or a read failure): the platform then skips the cache lookup and resolves fresh for this project and sha."
        digest_version: { type: string, nullable: true, description: 'The digest algorithm version paired with config_digest. Versions never mix in one comparison. Absent together with config_digest.' }
    ResolvedConfig:
      type: object
      description: 'Response body of resolveProjectConfig on 200: either a cache hit or a fresh resolution (see source).'
      required: [merged_yaml, resolved_sha, valid, source]
      properties:
        merged_yaml: { type: string, description: 'The resolved CI configuration at resolved_sha. Possibly empty when valid is false (GitLab reported the merge as invalid).' }
        config_digest:
          type: string
          nullable: true
          description: "The platform's own computed digest over the root CI config file and its local includes at resolved_sha, authoritative for the cache key; it may differ from the request's config_digest, which is never an error. Absent when the request carried no digest (an uncacheable resolution, returned uncached) or when the platform's own computation aborted: the merge still succeeds either way, but the result cannot be cached."
        digest_version: { type: string, nullable: true, description: 'Absent together with config_digest, for the same reasons.' }
        resolved_sha: { type: string, description: 'The commit sha this merged_yaml was resolved at. On a cache hit this is the sha of the cached resolution, which may differ from the sha you sent: identical config content shares one resolution (digest keying). Do not assert it equals your own sha.' }
        valid:
          type: boolean
          description: 'False when GitLab reported the CI config merge as invalid (a user error in the pushed config, not a resolution failure); merged_yaml may then be empty.'
        source:
          type: string
          enum: [cache, resolved]
          description: '`cache`: served from the platform''s stored resolutions with no provider call. `resolved`: a fresh resolution just completed.'
        includes:
          type: array
          items: { type: object }
          description: |-
            The include entries of the resolved config, same per-entry shape as snapshot.data.includes (location/raw/blob/contextProject/type/extra), so the CLI can attribute jobs on MR pipelines. Served on every fresh resolution and, on a cache hit, from the stored resolution. Zero includes are served as an explicit empty array, never as an absent key. Absent only on a cache hit against an older stored resolution, or when the entries could not be serialized at all; treat absence as unknown, never as "no includes". Entries carry the same observation keys as snapshot.data.includes (ref_exists_as_tag/ref_exists_as_branch, source_catalog on component includes, jobs/jobs_known) with the same semantics, described in full there. They are best-effort here: an observation that did not complete within the resolution's own time budget is absent on that response and lands in the cache for the next request on the same digest, so the same key can answer unobserved, then observed. Read jobs_known, never the emptiness of jobs; ignore unknown keys, including the obsolete latest_version and ref_is_ambiguous an older cached row may still carry.
        ci_errors:
          type: array
          items: { type: string }
          description: 'GitLab''s CI lint/merge errors for this resolution, verbatim (same name as snapshot.data.ci_errors). Always present: possibly non-empty when valid is false; `[]` on cache hits, since only valid resolutions are cached. The `valid` boolean is the only validity signal; there is no status field.'
    ResolveUnavailable:
      type: object
      description: 'Response body of resolveProjectConfig on 503: the resolution is unavailable. Never a fabricated result, and never a block.'
      required: [reason]
      properties:
        reason:
          type: string
          enum: [resolver_busy, resolution_unavailable]
          description: 'Fixed machine-readable vocabulary. resolver_busy: the per-instance concurrency cap was saturated. resolution_unavailable: the timebox was exceeded, the git host is unreachable, or no org token is configured for this instance.'
    ProjectUpdateEvent:
      type: object
      description: 'Payload of the `project.update` SSE event (the event name is `project.update`, not `project.status`).'
      required: [project]
      properties:
        project: { type: string }
        deployment_status: { $ref: '#/components/schemas/DeploymentStatus' }
        freshness:
          allOf: [ { $ref: '#/components/schemas/Freshness' } ]
          description: 'The project''s freshness (see Freshness). Omitted when the write did not change it, in particular on a closure-only event (see issues_closed).'
        score:
          allOf: [ { $ref: '#/components/schemas/Score' } ]
          description: 'Present only when it changed.'
        score_degraded:
          type: boolean
          description: 'The same flag InventoryProject.score_degraded carries: true when the run currently owning the displayed score was pushed with a degraded collection (an out-of-order push never flips it). When present it reflects the project''s current value as of this write, whether or not score changed on this event: apply it on every project.update carrying it, so the badge stays correct when the event changed only deployment_status or freshness. score and score_degraded are omitted together when the platform could not read the project''s current flag for this event; keep the prior values, the next successful event carries both again.'
        component_ref:
          type: string
          description: 'The project''s currently stored component pin, the same value the sync''s CI-file scan maintains. When present it reflects the project''s current value as of this write, not only on the push that changed it. Currently only a push carries this on the event; other project.update events omit it, so absence means "this event does not carry the fact", never "no component pinned". Omitted when empty, since "" is the "no component pinned" value.'
        component_outdated:
          type: boolean
          description: 'Whether component_ref is known to be behind the component''s newest release tag, the same value InventoryProject.component_outdated carries. Carried alongside component_ref, under the same "only a push carries it" caveat. Omitted whenever version_check is not `performed` and whenever component_ref is omitted; never a guessed false. See version_check and version_check_reason for why a known ref''s comparison is missing.'
        version_check:
          allOf: [ { $ref: '#/components/schemas/VersionCheck' } ]
          description: 'The same outcome InventoryProject.version_check serves, carried under the same "only a push carries it" caveat as component_ref. Omitted whenever component_ref is omitted (a project pinning no component has nothing to check); present only alongside a served component_ref, once this project''s pin has an outcome to report.'
        version_check_reason:
          type: string
          description: 'The same sentence InventoryProject.version_check_reason serves. Omitted whenever version_check is omitted; present (possibly empty, for a `performed` check with nothing to explain) whenever version_check is present. At most 512 bytes.'
        previous:
          type: object
          description: 'Prior deployment_status/freshness/score for transition display.'
        issues_closed:
          type: integer
          description: 'How many of this project''s issues this write closed (Fixed, system reason "policy removed") because a policy deletion or unassignment emptied their requiring-policies list. Present only when greater than zero. Such an event can arrive without freshness when the write closed issues on a project it did not stale; apply issues_closed whether or not freshness rides the event.'
    ReportExportEvent:
      type: object
      description: 'Payload of the `report.export` SSE event: a notification, not a delivery. Published once, when an on-demand export (createReportExport) reaches `ready` or `failed`. Carries only enough to know an export finished and to poll it, never the file, the filename, or the scope; the follow-up `getReportExport` is the only path that serves the report''s bytes. Delivered to the requesting user''s own session only. At most once, never guaranteed: a client that is not connected (or reconnecting) when it fires never receives it, and there is no replay. `getReportExport` remains the authoritative source of the outcome: a client that did not see this event, or is unsure, must still poll.'
      required: [export_id, status, created_at]
      properties:
        export_id: { type: string, format: uuid }
        status:
          type: string
          enum: [ready, failed]
          description: 'The export''s terminal status: ready or failed (pending never publishes).'
        created_at: { type: string, format: date-time, description: 'The export''s created_at, the same value getReportExport returns for this id.' }
    StreamResyncEvent:
      type: object
      additionalProperties: false
      description: 'Payload of the `stream.resync` SSE event. Sent on reconnect when the client''s `Last-Event-ID` is older than the server''s bounded 256-event replay window: the server can no longer prove nothing was missed in that gap, so the client must fully refetch whatever list or detail view it was tracking (a normal REST refetch). Always an empty object: the event name is the entire signal. Never replayed itself and never assigned an `id:` line (see streamEvents).'
    ConfigUpdateEvent:
      type: object
      description: 'Payload of the `config.update` SSE event. Published exactly once whenever a policy or portfolio write succeeds, regardless of how many projects the write staled (those still get their own `project.update` events). A notification, not a delivery: never the entity''s content or the affected project set; the client refetches the changed policy or portfolio. Delivered org-wide, never filtered per project.'
      required: [entity, id]
      properties:
        entity:
          type: string
          enum: [policy, portfolio]
          description: 'Which config surface changed.'
        id: { type: string, format: uuid, description: 'The changed policy''s or portfolio''s own id.' }
    FixUpdateEvent:
      type: object
      description: 'Payload of the `fix.update` SSE event. Published on every fix status transition, creation included, after the write succeeds. A notification, not a delivery: never the fix''s status or content; the client refetches the issue (listIssues or getIssue, whose `latest_fix_status` carries the current value), so any browser sees that a fix is in flight without re-triggering it. Delivered org-wide (Member minimum), never filtered per project.'
      required: [issue_id]
      properties:
        issue_id: { type: string, format: uuid, description: 'The issue whose latest_fix_status just changed. The client''s own follow-up refetches this issue (or the page containing it) to read the new value.' }
    SyncUpdateEvent:
      type: object
      additionalProperties: false
      description: 'Payload of the `sync.update` SSE event. Published when a sync run is queued by a manual trigger (POST /sync), started, completed (with or without a warning), failed on its final attempt, failed on a non-final attempt (the retry detail, which attempt failed and how many the job gets, is recorded on the still-live run; the run''s state is unchanged), throttled during listing progress (at most once per 5 seconds, never per project), and when an abandoned run is closed. One exception: an activity-driven archived re-check is not announced at queue time; its first event is the run start. Always an empty object: the event name is the entire signal, and the client refetches `GET /sync/status`. Delivered org-wide, matching syncStatus''s own org-wide read.'
    DeploymentStatus:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change.'
      enum: [missing, to integrate, in progress, onboarded, lost, unboarding, unboarded]
    Freshness:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change. Backend-managed; `running` is never a terminal state.'
      enum: [fresh, stale, running, no result received]
    NoResultReason:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change. Why a deployed project (onboarded or in progress) has still never produced an analysis result, read by the platform''s daily sync from the project''s default branch: its latest pipeline and that pipeline''s `plumber` job. Never a live provider call on a read path. `no_pipeline`: the default branch has no pipeline at all. `ci_invalid`: the provider itself rejected the pipeline''s configuration (its message is in no_result_detail). `job_absent`: the pipeline ran and its job list does not carry the `plumber` job (the component include never took on the default branch). `job_failed`: the job ended failed or canceled before it could push (the failure reason, or failing that the job status, is in no_result_detail). `unknown`: the platform read the provider and the answer is none of the four: the job is queued, running, manual or skipped, or it succeeded and no result reached this platform, or the job list was too large to read to the end, or the platform has no default branch on file. `unknown` means read and inconclusive; a project whose pipeline has never been read omits the field entirely (see InventoryProject.no_result_reason).'
      enum: [no_pipeline, job_absent, job_failed, ci_invalid, unknown]
    ProviderAbsenceReason:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change. Why a project stopped appearing on the provider''s listing, beside provider_deleted, which only says that it did. Read from the platform''s daily sync, never a live provider call on a read path. `deleted`: the provider says the project is gone or scheduled for deletion. `transferred`: the project moved out of the scope the org''s token lists and still exists. `hidden`: the project''s visibility changed so the org''s token can no longer see it. `unknown`: the absence was observed and nothing more. `unknown` means observed and inconclusive; a project whose absence has never been diagnosed omits the field entirely (see InventoryProject.provider_absence_reason). Today `unknown` is the only value the platform writes; the other three are declared now so that a later release changes no client.'
      enum: [deleted, transferred, hidden, unknown]
    SyncErrorClass:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change. What kind of failure ended a sync run''s last attempt, as the sync itself classified it: the same decision that writes the sentence in SyncStatus.error, so the two never disagree. `provider_window`: the platform''s own outbound provider window cut the call short and the provider was never asked; the remedy is PLUMBER_PROVIDER_LIMIT_SYNC, and raising the sync timeout only buys a longer wait. `interrupted`: the platform cut the run short itself (its own timebox, PLUMBER_SYNC_TIMEOUT, or a deploy cancelling in-flight jobs), never the provider''s fault. `provider_status`: the provider answered with a non-2xx status (a revoked org token answers 401). `provider_unreachable`: the provider could not be reached at all (a network-level failure). A failure that was not classified omits the field entirely (see SyncStatus.last_error_class), which is not the same as a run with no problem.'
      enum: [provider_window, interrupted, provider_status, provider_unreachable]
    VersionCheck:
      type: string
      description: 'Frozen vocabulary: these exact strings are stable and will not change. Whether the platform''s daily sync managed to compare this project''s pinned component ref against that component''s newest release tag, on the last pass that looked. `performed`: the component''s tags were listed and the pin was compared; the only value under which component_outdated is served at all. `skipped`: the check could not run: there is no component path to list, or the listing itself was refused or failed (version_check_reason says which). `unknown`: the listing ran and the comparison is inconclusive: the pin is a branch name or an unmatched commit sha, or the component has no released version to compare against. A project that pins no component omits the field entirely (see InventoryProject.version_check).'
      enum: [performed, skipped, unknown]
    CoverageState:
      type: string
      description: 'Derived coverage classification, never stored. Computed from deployment_status, freshness, and whether the deployed Plumber component version is outdated. `outdated` wins only when freshness is current (fresh or running); a stale or no-result project is `stale` even when its component is also outdated. Coverage reads the project''s stored component_outdated value, the last real comparison''s answer, even when that field is omitted from the response because the most recent version check did not run: a project can show `coverage: outdated` with no component_outdated key beside it.'
      enum: [covered, uncovered, stale, outdated]
    InstanceSettings:
      type: object
      description: 'OrgAdmin settings view of one configured git instance: metadata and flags only, never a secret.'
      required: [id, provider, instance, token_set, oauth_app_set, scope_pending, component_path, component_ref, component_resolution, integration_mode, external_ci_config_location, external_ci_config_check, external_ci_config_snippet]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string }
        instance: { type: string }
        base_url: { type: string, nullable: true, description: 'Provider instance base URL; null until an OAuth app is configured.' }
        token_kind:
          type: string
          nullable: true
          enum: [api, read_api]
          description: 'Derived from the validated token''s scopes, never caller input. Null when no token is configured.'
        token_expires_at: { type: string, format: date-time, nullable: true }
        oauth_client_id: { type: string, nullable: true, description: 'Public OAuth app client id (not a secret); null until configured.' }
        token_set: { type: boolean, description: 'True when an org token is configured. The token value itself is never returned.' }
        oauth_app_set: { type: boolean, description: 'True when a per-instance OAuth app is configured. The client secret value is never returned.' }
        scope_type:
          type: string
          nullable: true
          enum: [instance, group]
          description: 'Connection scope: `instance` (the whole git instance) or `group` (a single root group). Null when the instance has not been configured with a scope yet.'
        root_group_path: { type: string, nullable: true, description: 'For a group-scoped instance, the full path of the root group (e.g. bigtech-500). Null for instance scope.' }
        root_group_name: { type: string, nullable: true, description: 'For a group-scoped instance, the display name of the root group, filled in once the group is resolved. Null for instance scope or while still pending.' }
        scope_pending: { type: boolean, description: 'True for a group-scoped instance whose root group has not been resolved yet: it was created with a path but the org token has not been set to resolve the path to the immutable group id. A pending instance is not usable yet (it shows no projects and syncing it fails until the token is set).' }
        component_path: { type: string, description: 'The project path (on this instance) of the component to add when onboarding a project, e.g. plumber-org/plumber-component. Empty string when not configured yet.' }
        component_ref: { type: string, description: 'The immutable ref (tag or commit) of the component to pin, e.g. v1.2.3. Never a floating name (latest, main, master, HEAD). Empty string when not configured yet, and when a component_path is configured in the resolved mode: read component_resolution to tell those two apart, never the emptiness of this field alone.' }
        component_resolution:
          type: string
          enum: [resolved, pinned, unset]
          description: 'Which mode this instance''s onboarding component is configured in, derived from component_path and component_ref. `unset`: no component_path configured, nothing is added to a CI file at all. `resolved`: a component_path with an empty component_ref; the platform resolves the newest immutable tag of that component repository at MR time and refreshes it as releases land. `pinned`: a component_path with an explicit component_ref, moved only by re-saving this setting.'
        integration_mode:
          type: string
          enum: [ci_file, external_ci_config]
          description: 'How onboarding integrates a project on this instance. ci_file (the default): a merge request adds the component include to the project''s CI file. external_ci_config: the project''s CI configuration path setting is pointed at the central file named by external_ci_config_location, which the customer owns; no file is written in the project. The platform reads only that central file outside a project: a project whose CI configuration path names any other external file is not read (its detection is inconclusive and its configuration is not collected or resolved), and in ci_file mode no external file is read at all.'
        external_ci_config_location:
          type: string
          description: 'The central CI file every project is pointed at in external_ci_config mode, as GitLab writes it in a project''s CI configuration path: path@group/project, or path@group/project:ref. Empty when not configured.'
        external_ci_config_check:
          type: object
          required: [status]
          description: 'What the last check of the central file found. Checked on every settings write in external_ci_config mode and once per synchronization pass in that mode; cleared by a settings write in ci_file mode.'
          properties:
            status: { type: string, enum: ['', ok, unreadable, component_missing], description: 'Empty until first checked. ok: the file exists and includes this instance''s component with the platform input of this installation. unreadable: the file could not be read when last checked: absent at its ref, not visible to the instance token, or a transport failure; a re-save or the next synchronization pass re-checks it. component_missing: the file exists but does not include the component with that input, or includes it at a ref that is not a version (the snippet''s <version> placeholder left in place).' }
            checked_at: { type: string, format: date-time, nullable: true }
        external_ci_config_snippet:
          type: string
          description: 'The content to put in the central file, rendered from this instance''s component and this installation''s platform input, with workflow rules that run the pipeline on the default branch and on schedules. Empty when no component is configured. When the instance does not pin a component version, the ref reads <version>: replace <version> with the component version to pin. The stage input reads test: a pipeline whose only job sits in the component''s default stage .pre does not run on GitLab, so a central file holding one include needs a stage of its own; drop the stage input only if your component declares none.'
    InstanceSettingsList:
      type: object
      description: 'The listSettingsInstances response envelope: one page of InstanceSettings rows, ordered by (provider, instance). Offset-paginated.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/InstanceSettings' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    LoginInstance:
      type: object
      description: 'One login-capable git instance, as served to the unauthenticated login picker. Closed field set: id, provider, name and base_url_host only.'
      required: [id, provider, name]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string }
        name: { type: string, description: 'The instance name, e.g. default.' }
        base_url_host: { type: string, nullable: true, description: 'The host portion of the instance base URL (e.g. gitlab.example.com), for display. A login-capable instance always has a base URL, so this is null only when that value is unparseable.' }
    LoginInstanceList:
      type: object
      description: 'The listLoginInstances response envelope: every login-capable instance, ordered by instance name. Unpaginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/LoginInstance' }
    MemberInstance:
      type: object
      description: 'One connected git instance of the caller''s org, as served by GET /instances, for building repository links. Closed field set: none of the OrgAdmin-only token, OAuth or scope fields of InstanceSettings.'
      required: [id, provider, name]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string }
        name: { type: string, description: 'The instance name, e.g. default.' }
        base_url:
          type: string
          nullable: true
          description: 'Provider instance base URL. Null until an OrgAdmin configures it (createInstance or the settings flow).'
        root_group_path:
          type: string
          nullable: true
          description: 'For a group-scoped instance, the full path of the root group (e.g. bigtech-500); null for instance scope. The same value as InstanceSettings.root_group_path, readable by every role, so a client can strip the org''s group-scope prefix off a project''s full path for display without an OrgAdmin-only settings lookup.'
    MemberInstanceList:
      type: object
      description: 'The listInstances response envelope: every connected git instance of the caller''s org, ordered by (provider, name). Unpaginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/MemberInstance' }
    UpdateInstanceTokenRequest:
      type: object
      required: [token]
      properties:
        token: { type: string, description: 'Plaintext candidate org token, validated against the git host before storage; never echoed back.' }
    UpdateInstanceRootGroupRequest:
      type: object
      required: [root_group_path]
      properties:
        root_group_path: { type: string, maxLength: 1024, description: 'The full path of the new root group, e.g. bigtech-500 or bigtech-500/platform. At most 1024 characters and free of control characters; anything else is a 400.' }
    UpdateInstanceOAuthAppRequest:
      type: object
      required: [client_id, client_secret, base_url]
      properties:
        client_id: { type: string, description: 'Public OAuth app client id.' }
        client_secret: { type: string, description: 'Plaintext OAuth app client secret; never echoed back.' }
        base_url: { type: string, description: 'Provider instance base URL (e.g. https://gitlab.example.com); the server-side source for OAuth endpoints and token validation.' }
    UpdateInstanceComponentRequest:
      type: object
      required: [component_path]
      description: 'Body of updateInstanceComponent: the component an onboarding action adds to a project''s CI file. component_path is always required (an explicit "" clears a previously configured setting); component_ref is optional, see its own description.'
      properties:
        component_path: { type: string, description: 'The project path (on this instance) of the component to add, e.g. plumber-org/plumber-component. Must not carry a URL scheme, a host, or an @ sign. Explicit "" clears the setting; a component_ref with an empty component_path is rejected (422) - it names nothing to pin.' }
        component_ref: { type: string, description: 'Optional: absent or "" with a component_path means the platform resolves the newest immutable tag of the component repository at MR time and refreshes it as releases land; a present value is an explicit pin and follows the pin rules (never a floating name; verified when the MR is opened).' }
    UpdateInstanceIntegrationRequest:
      type: object
      description: 'Body of updateInstanceIntegration. Both fields are optional: an absent field leaves the stored value unchanged.'
      properties:
        integration_mode: { type: string, enum: [ci_file, external_ci_config] }
        external_ci_config_location: { type: string, description: 'path@group/project or path@group/project:ref, a file in a project of this instance. Needed while integration_mode is external_ci_config; an empty string clears it, which is refused while the mode is external_ci_config.' }
    ComponentVerification:
      type: object
      description: 'The 200 body for updateInstanceComponent: whether the just-stored component pin could be checked against the provider at write time. An unverified result never blocks the write; see that operation for the full verified/unverified split.'
      required: [verification]
      properties:
        verification:
          type: object
          required: [status, reason]
          properties:
            status:
              type: string
              enum: [verified, unverified]
              description: '`verified`: the pin was resolved against the provider and its spec declares the platform input, or the write cleared the setting (an empty component_path). `unverified`: the resolution could not be completed for a benign reason (no token or base_url configured yet, the component template unreadable or unparseable at this ref, a transport failure, or a resolved-mode save, which has no version to check yet); the setting is still stored, never blocked.'
            reason:
              type: string
              description: 'Empty string when status is verified (including a clear). A short cause when status is unverified, e.g. "no token configured". A save with a component_path and no component_ref reports "no version pinned yet; the resolved tag is verified at deployment time": nothing was checked because there is nothing to check yet, which is not a provider failure.'
    ComponentVersionCheck:
      type: object
      description: 'The 200 body for checkInstanceComponentVersion: the instance''s current component pin alongside its component repository''s newest published tag (a repository-wide lookup, see latest), or a degraded answer when that resolution could not complete on this request. See the operation for the full checked/reason contract.'
      required: [current, checked, reason, resolution]
      properties:
        current:
          type: object
          required: [path, ref]
          description: 'The instance''s stored component pin (InstanceSettings.component_path/component_ref): both empty strings when nothing is configured yet, never omitted. One exception on ref, described there.'
          properties:
            path: { type: string, description: 'The pinned component''s project path, as stored. Empty string when unconfigured.' }
            ref:
              type: string
              description: 'The pinned component''s immutable ref, as stored; empty string when unconfigured. In the resolved mode (resolution `resolved`, nothing stored) this field carries the live newest tag this check just resolved, the ref an onboarding MR would pin right now, which is the same value as latest.ref; it falls back to "" whenever that resolution did not complete (checked false, reason says why).'
        resolution:
          type: string
          enum: [resolved, pinned, unset]
          description: 'Which mode the instance''s component pin is in, derived by the same rule and carrying the same three values as InstanceSettings.component_resolution. `unset`: no component_path configured at all, so neither live mode applies (checked is false, and reason says so). `pinned`: a ref is stored. `resolved`: a path is configured without a ref; the platform picks the newest immutable tag at MR time and refreshes it as releases land.'
        checked: { type: boolean, description: 'True only when the component repository''s newest published tag was resolved against the provider on this request. False on any failure; see reason for the category.' }
        reason:
          type: string
          description: 'Empty string ("") exactly when checked is true. Exactly one of these literal categories when checked is false: "no component configured for this instance" (current.path is empty); "no org token configured yet"; "no base_url configured for this instance yet"; "component repository unreachable"; "could not resolve a published version for the component repository" (the provider was reached but no version could be resolved: a transport failure listing tags, or no semver-parseable tag).'
        latest:
          type: object
          nullable: true
          required: [version, ref]
          description: 'The component repository''s newest semver-parseable git tag, or null when checked is false. This is a repository-wide resolution, not per-component: for a repository hosting several independently released components, the newest tag can be a release that never touched the pinned component.'
          properties:
            version: { type: string, description: 'The resolved tag''s own display version string - identical to ref (a git tag name serves as both).' }
            ref: { type: string, description: 'The resolved tag''s own immutable ref - the same kind of value component_ref pins, and identical to version (a git tag name serves as both).' }
        changelog_url:
          type: string
          nullable: true
          description: 'A tags-page link for the latest version (base_url + the component repository path + "/-/tags/" + the resolved tag). Never null when checked is true on GitLab; always null when checked is false.'
    OrgSettings:
      type: object
      description: 'The org settings document (getOrgSettings/updateOrgSettings): org-level toggles and settings, all required on every write (full replace).'
      required: [auto_fanout_enabled, mr_comment_enabled, summary_issue_enabled, badge_enabled, issue_auto_assign_enabled, community_link_hidden, branch_prefix, commit_prefix, timezone]
      properties:
        auto_fanout_enabled: { type: boolean, description: 'Whether the platform automatically re-checks projects made stale by a configuration change, at a bounded rate, instead of waiting for their next regular pipeline run. Off by default.' }
        mr_comment_enabled: { type: boolean, description: 'Whether the platform posts and updates a single summary comment (finding delta, score, gate line) on an analyzed push''s open MR/PR. Off by default.' }
        summary_issue_enabled: { type: boolean, description: 'Whether the platform creates and updates a single rolling native issue per project, summarizing detected, in-progress and fixed findings. Never per-finding mirroring. Off by default.' }
        badge_enabled:
          type: boolean
          description: 'Whether project score badges may be served publicly for this org. This toggle alone makes no badge public: a project''s badge serves only when this is on, the project''s own badge toggle is on (`PUT /api/v1/projects/{id}/badge`, OrgAdmin), and the project''s observed visibility is exactly `public`. Off by default.'
        issue_auto_assign_enabled: { type: boolean, description: 'Whether the platform automatically sets a maintainer/owner of the originating project or group as a created issue''s owner. No maintainer resolvable never blocks issue creation. Off by default.' }
        community_link_hidden:
          type: boolean
          description: 'Whether the application hides its community (Discord) entry for this org. False by default: the entry stays visible until an org hides it. Required on every write like every field of this document: a write that omits it is rejected (400). The same value is served to every role on `GET /me` as `org_ui.community_link_hidden`.'
        branch_prefix:
          type: string
          description: 'The branch-name prefix Plumber uses in place of its built-in "plumber/" segment on every MR/PR it opens (onboarding, fix, bump). It replaces that segment; the descriptive suffix that carries the per-MR uniqueness is untouched. Validated as a legal git ref segment at write time (400, naming the field); a trailing "/" is appended when absent, so "acme-sec" and "acme-sec/" behave identically. Empty (the default) keeps the built-in "plumber/" convention. Capped at 64 bytes.'
        commit_prefix:
          type: string
          description: 'The commit-message prefix Plumber uses in place of its built-in "fix(plumber): " prefix on every commit it writes, and the title prefix of every MR/PR it opens; the message body after the prefix is untouched. Validated at write time (400, naming the field): no control characters, no newline. Stored and applied byte-exact: unlike branch_prefix, no separator is appended, so "chore(sec):" yields "chore(sec):bump x to y" while "chore(sec): " yields "chore(sec): bump x to y"; include the trailing space if you want one. Empty (the default) keeps the built-in "fix(plumber): " convention. Capped at 64 bytes.'
        timezone:
          type: string
          description: 'The IANA time zone name (e.g. "UTC", "America/New_York") the scheduled report''s cron is interpreted in; see updateReportSchedule for how it composes with a schedule''s cron and the catch-up policy. Validated at write time (400, naming the field); "" and "Local" are rejected. "UTC" by default for every org that has never set it.'
    OnboardingStatus:
      type: object
      description: 'Body of getOnboardingStatus: the stored onboarding "done" flag plus eight signals derived live from existing data; see that operation for what each signal means.'
      required: [done, token_set, sync_has_run, any_project_onboarded, licence_set, component_set, any_policy_created, ai_configured, licence_exceeded]
      properties:
        done: { type: boolean, description: 'The stored onboarding "done" flag, set only by updateOnboardingStatus. False for every org until an OrgAdmin explicitly marks it done.' }
        token_set: { type: boolean, description: 'True if any git instance for this org has an org token stored: the same presence check as InstanceSettings.token_set, applied to any instance.' }
        sync_has_run: { type: boolean, description: 'True if any sync run for this org has completed successfully, never a merely-started or a failed/abandoned one.' }
        any_project_onboarded: { type: boolean, description: 'True if any project for this org has deployment_status = "onboarded".' }
        licence_set: { type: boolean, description: 'True if a licence key is stored for this org and its signature validates, the same check LicenceStatus.licensed reports. Expiry is not checked: an expired stored key still reports true, so this means "a key was installed", not "the paid tier is currently in effect" (see LicenceStatus.expired).' }
        component_set:
          type: boolean
          description: 'True if any git instance for this org has a component_path configured (a configured path with an empty component_ref is the resolved mode, still configured); applied to any instance, like token_set.'
        any_policy_created: { type: boolean, description: 'True if any policy exists for this org, derived live like its siblings, so the onboarding wizard''s Policies step and the resume banner need no second GET /config/policies.' }
        ai_configured: { type: boolean, description: 'True if AISettings.enabled is currently true for this org - the same derived enabled state getAISettings itself reports, never a separate stored flag.' }
        licence_exceeded:
          type: boolean
          description: 'True when usage.counted exceeds the project limit under the tier in effect (the free tier''s 10 while the installed key is expired), the same figure GET /settings/licence reports as over_limit. Surfaced, never gated: ingest and the deployment of a hand-wired project are never refused; only the onboarding wizard refuses past the limit (422).'
    UpdateOnboardingStatusRequest:
      type: object
      description: 'Body of updateOnboardingStatus: replaces the stored "done" flag with exactly the given value.'
      required: [done]
      properties:
        done: { type: boolean, description: 'The new value to store. Both true and false are valid, equally-supported writes.' }

    LicenceStatus:
      type: object
      description: 'Body of getLicenceStatus: the org''s current licence state, both the claimed values of an installed key and the effective tier in force. See getLicenceStatus for the licensed/expired split.'
      required: [licensed, expired, owner, expires_at, level, project_limit, usage, over_limit, remaining]
      properties:
        licensed: { type: boolean, description: 'True when a licence key is installed and its signature currently validates. False when no key is installed, or the installed key''s signature does not validate.' }
        expired: { type: boolean, description: 'True when the installed key has passed its own expires_at, so the paid tier''s claimed values are not in effect (a silent degrade to the free tier, never an error); false while the key is within its validity window, and always false when licensed is false.' }
        owner: { type: string, description: 'When licensed is true: the licence key''s own claimed owner name. When licensed is false: "" (the free tier has no owner).' }
        expires_at:
          type: string
          description: 'When licensed is true: the licence key''s own claimed expiry, an RFC 3339 full-date (year-month-day, not a date-time), copied verbatim from the signed licence. When licensed is false: "" (the free tier never expires).'
        level: { type: integer, description: 'When licensed is true: the licence key''s own claimed level. When licensed is false: 0 (the free tier''s level).' }
        project_limit: { type: integer, description: 'When licensed is true: the licence key''s own claimed project limit. When licensed is false: 10 (the free tier''s project limit).' }
        usage:
          $ref: '#/components/schemas/LicenceUsage'
        over_limit: { type: boolean, description: 'True when usage.counted exceeds the project limit under the tier in effect (the free tier''s 10 while the installed key is expired), not necessarily project_limit above, which always reports the claimed value. Surfaced, never gated: ingest and the deployment of a hand-wired project are never refused; only the onboarding wizard refuses past the limit (422).' }
        remaining: { type: integer, description: 'How many more projects can be onboarded before over_limit flips true, under the tier in effect (the free tier''s 10 while the installed key is expired), not necessarily project_limit above, which always reports the claimed value. 0 when already over_limit, never negative.' }
    LicenceUsage:
      type: object
      description: 'The counted-usage set LicenceStatus.usage carries: every project that counts against project_limit, regardless of which tier (paid or free) is actually in effect.'
      required: [counted, project_ids]
      properties:
        counted: { type: integer, description: 'Count of project_ids below: projects in deployment state `in progress`, `onboarded`, or `unboarding`, plus any project with a live onboarding attempt in status `pending` or `mr_opened` that has not yet flipped the project''s own deployment state; a project satisfying both counts once.' }
        project_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'The ids making up the count above.'
    ApplyLicenceRequest:
      type: object
      description: 'Body of applyLicence: the licence key to validate and store, or an empty string to clear any stored key.'
      required: [key]
      properties:
        key: { type: string, description: 'Licence key material. An empty string clears any stored key (reverts to the free tier immediately). A non-empty value must be valid base64, its signature must validate, and it must not already be expired (400 otherwise) - see applyLicence''s own description for the apply-time-reject vs later-silent-degrade split.' }

    AISettings:
      type: object
      description: 'Body of getAISettings: the org''s configured LLM connection, plus three presence booleans (`api_key_set`, `bedrock_access_keys_set`, `bedrock_api_key_set`), never the secret values themselves; `bedrock_session_token`''s presence is not observable by any read. The platform only stores this configuration: nothing in it calls an LLM.'
      required: [enabled, provider, endpoint, model, model_light, api_key_set, bedrock_region, bedrock_model, bedrock_model_light, bedrock_auth_method, bedrock_access_keys_set, bedrock_api_key_set]
      properties:
        enabled: { type: boolean, description: 'True only when the current provider''s own completeness set is fully met - see updateAISettings for the two sets. Never true otherwise, regardless of what a caller last tried to write.' }
        provider:
          type: string
          enum: [openai, bedrock]
          description: 'Which LLM connection this org has configured. Defaults to `openai` until an OrgAdmin explicitly configures a provider (enabled stays false regardless until that provider''s completeness set is met).'
        endpoint: { type: string, description: 'openai-provider field: the API endpoint URL. Empty string when not configured, or when provider is bedrock.' }
        model: { type: string, description: 'openai-provider field: the model name. Empty string when not configured, or when provider is bedrock.' }
        model_light: { type: string, description: 'openai-provider field: the lighter/cheaper model name for smaller tasks. Empty string when not configured, or when provider is bedrock.' }
        api_key_set: { type: boolean, description: 'True when an openai-provider api key is stored. The key value itself is never returned.' }
        bedrock_region: { type: string, description: 'bedrock-provider field: the AWS region. Empty string when not configured, or when provider is openai.' }
        bedrock_model: { type: string, description: 'bedrock-provider field: the model id. Empty string when not configured, or when provider is openai.' }
        bedrock_model_light: { type: string, description: 'bedrock-provider field: the lighter/cheaper model id for smaller tasks. Empty string when not configured, or when provider is openai.' }
        bedrock_auth_method:
          type: string
          enum: ['', apiKey, accessKeys]
          description: 'How the bedrock connection authenticates: `""` (not configured yet), `apiKey` (a single bedrock api key), or `accessKeys` (an AWS access-key-id/secret-access-key pair, optionally with a session token).'
        bedrock_access_keys_set: { type: boolean, description: 'True when the bedrock access-key-id/secret-access-key pair is stored. Neither value is ever returned.' }
        bedrock_api_key_set: { type: boolean, description: 'True when a bedrock api key is stored. The key value itself is never returned.' }
    UpdateAISettingsRequest:
      type: object
      description: 'Body of updateAISettings: a partial update, never a full replace; see that operation for the absent-keeps/empty-string-clears rule and the two per-provider completeness sets required to set enabled true. No field is required; an empty body is a legal no-op write.'
      properties:
        enabled: { type: boolean, description: 'Absent keeps the current stored value. When given true, the resulting provider''s completeness set (checked against the value that would be stored after this write) must be fully met, or the write 400s naming the missing fields.' }
        provider: { type: string, enum: [openai, bedrock], description: 'Absent keeps the current stored value. No empty-string clear (provider is never blank once set); must be openai or bedrock when supplied.' }
        endpoint: { type: string, description: 'openai-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        model: { type: string, description: 'openai-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        model_light: { type: string, description: 'openai-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        api_key: { type: string, description: 'openai-provider field, plaintext, never echoed back by any read. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_region: { type: string, description: 'bedrock-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_model: { type: string, description: 'bedrock-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_model_light: { type: string, description: 'bedrock-provider field. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_auth_method: { type: string, enum: ['', apiKey, accessKeys], description: 'Absent keeps the current stored value; must be "", apiKey, or accessKeys when supplied.' }
        bedrock_access_key_id: { type: string, description: 'bedrock-provider field, part of the access-keys pair. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_secret_access_key: { type: string, description: 'bedrock-provider field, part of the access-keys pair, plaintext, never echoed back by any read. Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_session_token: { type: string, description: 'bedrock-provider field, optional even within the access-keys auth method (a temporary-credentials session token). Absent keeps the current stored value; an explicit empty string clears it.' }
        bedrock_api_key: { type: string, description: 'bedrock-provider field, plaintext, never echoed back by any read. Absent keeps the current stored value; an explicit empty string clears it.' }

    CreateInstanceRequest:
      type: object
      description: 'Body of createInstance: registers a git instance; its token and OAuth app are configured afterwards via the PUT endpoints.'
      required: [provider, instance, base_url, scope_type]
      properties:
        provider: { type: string, description: 'The provider kind, e.g. gitlab (GitHub instances are not supported by this endpoint yet).' }
        instance: { type: string, description: 'The instance name, e.g. gitlab.example.com; distinct from base_url.' }
        base_url:
          type: string
          description: 'The provider instance base URL; must be a parseable absolute http(s) URL.'
        scope_type:
          type: string
          enum: [instance, group]
          description: 'Connection scope: `instance` connects the whole git instance; `group` connects a single root group named by root_group_path. Immutable once set.'
        root_group_path: { type: string, description: 'Required when scope_type is group: the full path of the root group (e.g. bigtech-500); ignored for instance scope. The group id is resolved from this path when the org token is set, not at create time.' }
    SyncTriggerRequest:
      type: object
      description: 'Body of triggerSync: names the instance to sync.'
      required: [instance_id]
      properties:
        instance_id: { type: string, format: uuid, description: 'The instance to sync; must belong to the caller''s own org (404 otherwise).' }
    SyncState:
      type: string
      description: 'The sync run''s current lifecycle stage. `queued` is the window between the trigger being accepted (202) and the sync actually starting.'
      enum: [queued, in_progress, completed, failed]
    SyncStatus:
      type: object
      description: 'One sync run''s status.'
      required: [id, instance_id, scheduled, state, started_at, updated_at, project_count, group_count]
      properties:
        id: { type: string, format: uuid }
        instance_id: { type: string, format: uuid }
        scheduled: { type: boolean, description: 'True for a scheduled run, false for a manually triggered run.' }
        state: { $ref: '#/components/schemas/SyncState' }
        started_at: { type: string, format: date-time, description: 'While state is queued, when the run was requested (no work has started yet); once state becomes in_progress, the true work-start time.' }
        updated_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true, description: 'Null while state is queued or in_progress.' }
        project_count: { type: integer }
        group_count: { type: integer }
        error: { type: string, description: 'A short summary of why the run failed, present only when state is failed; never includes credentials or other secrets. Usually a provider failure. A run older than the longest a sync job may live is also reported failed rather than in_progress, since it cannot still be running.' }
        attempt:
          type: integer
          description: 'The job attempt this run is currently on: 1 on the first try, 2 once the first failed and the job was retried, and so on. Present only while state is in_progress and an attempt has already failed; omitted from the body (never a literal null) otherwise, including on completed or failed runs and on runs that predate this field. Check for the key''s presence: an absent attempt means nothing was recorded, not that nothing went wrong.'
        max_attempts:
          type: integer
          description: 'How many attempts the job gets in total (4 for a sync job). Present exactly when attempt is present (so only while state is in_progress), omitted otherwise. Render the pair as "attempt n of m"; do not compute a percentage or an ETA from them, since the backoff between attempts is not uniform.'
        last_error_class:
          allOf: [ { $ref: '#/components/schemas/SyncErrorClass' } ]
          description: 'The class of the failure that ended this run''s last attempt; see SyncErrorClass. Present only while state is in_progress and attempt is present; omitted from the body (never a literal null, never an empty string) once the run is completed or failed, and whenever the failure was never provider-classified (no token configured for the instance, an unsupported provider, an unconfigured connection scope, a platform-side write failure). Read an absent class as "not diagnosed", never as "no problem".'
        warning:
          type: string
          nullable: true
          description: 'Present only when this run withheld every provider_deleted flag it would otherwise have set, because the provider view narrowed past the configured thresholds (a token that stays valid but sees less of the estate) rather than genuine deprovisioning. Not a failure: state is still `completed`. Null or absent on every other run, including a genuine small-scale deletion.'
    SyncStatusList:
      type: object
      description: 'The syncHistory response envelope: one offset-paginated page of SyncStatus rows, newest first (`started_at` descending, `id` as tie-break).'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SyncStatus' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    RecheckResult:
      type: object
      description: 'The 202 body of recheckPolicy, recheckPortfolio and recheckOrg: how many projects were enqueued. Both counts cover only the projects the caller can see.'
      required: [triggered, skipped]
      properties:
        triggered: { type: integer, description: 'Visible stale-family members (stale, no result received) that were enqueued.' }
        skipped: { type: integer, description: 'Visible members not enqueued because they are already running or fresh. Excludes anything not visible to the caller.' }
    InventoryProject:
      type: object
      description: 'One entry in the visibility-filtered asset inventory (see listProjects).'
      required: [id, path, name, name_with_namespace, provider_project_id, deployment_status, freshness, coverage, has_plumber_yaml, archived, provider_deleted, score_letter, score_points, score_degraded, score_profile, newest_run_unscorable, component_ref, is_component_repo, official_component_repo, last_onboarding_error, policy_count, portfolio_count, policy_ids, portfolio_ids, open_issue_count, score_source, worst_live_branch, worst_live_branch_score_points, worst_live_branch_score_letter, integration_method]
      properties:
        id: { type: string, format: uuid }
        path: { type: string }
        name:
          type: string
          description: 'The project''s display name as the provider''s listing reports it, refreshed on each sync (never a live provider call). Always present, but "" for a project not synced since this field was added; fills in on the project''s next sync.'
        name_with_namespace:
          type: string
          description: 'The project''s full display path (namespace + name) as the provider''s listing reports it, refreshed on each sync; "" until the project''s next sync. Never assembled by the platform.'
        provider_project_id:
          type: string
          description: 'The provider''s own project id, as the sync stored it; distinct from id. Present on both listProjects and getProject. The register picker (POST /catalog/repos) posts this value verbatim as its project_id, and the "Imported" pill matches an already-registered catalog repo by this id.'
        deployment_status: { $ref: '#/components/schemas/DeploymentStatus' }
        freshness: { $ref: '#/components/schemas/Freshness' }
        no_result_reason:
          allOf: [ { $ref: '#/components/schemas/NoResultReason' } ]
          description: 'Why this project has never produced a result, as the daily sync last read it off the project''s default-branch pipeline; see NoResultReason. Present only alongside `freshness: no result received`; omitted from the body (never a literal null) when a result has since arrived or the sync has not yet read this project''s pipeline. Check for the key''s presence; it fills in on the next sync.'
        no_result_detail:
          type: string
          description: 'The provider''s own sentence for no_result_reason (the pipeline''s yaml errors for `ci_invalid`, the job''s failure reason for `job_failed`), passed through as written and truncated at 2048 bytes. For `unknown` it names what was read instead; for `no_pipeline` and `job_absent` it is an empty string. Present exactly when no_result_reason is present, omitted (never a literal null) otherwise. Display it as provider text.'
        coverage: { $ref: '#/components/schemas/CoverageState' }
        has_plumber_yaml: { type: boolean, description: 'Discovery flag only: whether a Plumber config file was found. Does not by itself assign any policy.' }
        archived: { type: boolean }
        provider_deleted: { type: boolean, description: 'True when the project was flagged as removed on the provider; the project is never actually deleted from this inventory.' }
        provider_absence_reason:
          allOf: [ { $ref: '#/components/schemas/ProviderAbsenceReason' } ]
          description: 'Why this project stopped appearing on the provider''s listing, as the daily sync recorded it when it set provider_deleted; see ProviderAbsenceReason. Present only alongside `provider_deleted: true`; omitted from the body (never a literal null) when the project is present on the provider or its absence was never diagnosed. Check for the key''s presence.'
        score_letter:
          type: string
          description: 'Empty string when the project has never been scored, or (with newest_run_unscorable true) when its very first push had no scorable policy. Otherwise the project''s last scored state, which stays displayed while newest_run_unscorable is true; such a project is excluded from every as-of average, so this grade and a fleet average can disagree by construction.'
        score_points: { type: integer }
        score_degraded: { type: boolean, description: 'True when the run owning the displayed score (score_letter/score_points) carried collection_degraded on push. Follows the same display ownership and ordering guard as score_letter/score_points: an out-of-order push never flips it. Aggregates (org, portfolio and policy scores, score histories) carry no such flag.' }
        score_profile:
          type: string
          description: 'The scoring formula that produced score_letter and score_points: `scoring-v3`, `scoring-v4` (the contextual score), a newer name from a later CLI, or `mixed` when the policies of the run owning the score were scored by different formulas. Meaningless while the project is unscored. Follows the same display ownership as score_letter.'
        newest_run_unscorable:
          type: boolean
          description: 'True when the newest analysis run had no scorable policy: every pushed result''s policy was unresolved, every policy withheld its score because none of its controls was evaluated, or the run evaluated nothing at all. The score fields then keep the last scorable state rather than being wiped. Resets to false on the next newest push with at least one scored policy.'
        score_source:
          type: string
          enum: [default_branch, fallback_any_branch]
          description: 'Which branch owns score_letter/score_points/score_degraded. `default_branch` once this project has ever received a run stamped as a default-branch run: from then on only another default-branch run moves the displayed score (a feature-branch push still stores its run and still gates). `fallback_any_branch` before that: the newest run of any branch owns the display, so the grade may not reflect the default branch. The first default-branch push flips the value permanently. Independent of worst_live_branch, which never feeds the displayed score, the rollups, the gate or the badge.'
        worst_live_branch:
          type: [string, 'null']
          description: 'The name of this project''s worst-scoring live branch (a branch whose newest run is less than 30 days old, a fixed window) among every branch that has ever pushed, independent of score_source; never used for the displayed score, the rollups, the gate or the badge. A real JSON null (never omitted) when no live branch has a scorable run. Updated at ingest and by a daily sweep, so a branch aging past the window can lag out of this value by at most one day. See GET /projects/{id}/branches for the live per-branch survey.'
        worst_live_branch_score_points:
          type: [integer, 'null']
          description: 'worst_live_branch''s own latest run''s score_points; null exactly when worst_live_branch is null (see that field for the shared ownership and lag facts).'
        worst_live_branch_score_letter:
          type: [string, 'null']
          description: 'worst_live_branch''s own latest run''s score_letter; null exactly when worst_live_branch is null (see that field for the shared ownership and lag facts).'
        component_ref: { type: string, description: 'The pinned ref of the deployed Plumber component, as parsed from the default-branch CI config. Display only (coverage separately folds in whether this ref is outdated). May be empty when no component include was ever detected.' }
        component_outdated:
          type: boolean
          nullable: true
          description: 'Whether the deployed Plumber component''s pinned ref is behind the newest available version, as last judged by the sync (never recomputed on request). Omitted from the body (never a literal null) when component_ref is empty (no comparison has ever run) and whenever version_check is not `performed`; check for the key''s presence. Read version_check and version_check_reason for why it is missing; coverage still derives from the stored value either way (see CoverageState).'
        version_check:
          allOf: [ { $ref: '#/components/schemas/VersionCheck' } ]
          description: 'Whether the daily sync managed to check this project''s pinned component version on the last pass that looked; see VersionCheck. Present only when the project pins a component and the sync has checked it since; omitted from the body (never a literal null) otherwise. Check for the key''s presence and read an absent key as "not checked yet"; on a large instance it can take more than one sync pass to fill in.'
        version_check_reason:
          type: string
          description: 'A one-line reason for a version_check that is `skipped` or `unknown`: "the org token cannot read <component path>" (the component repository answered 401/403/404), "the tag listing failed" (any other failure), "no component path", "the pin <ref> is not a version" (a branch name or an unmatched sha), or "no tags" (no released version). Empty string for a `performed` check. At most 512 bytes. Present exactly when version_check is present, omitted (never a literal null) otherwise.'
        is_component_repo:
          type: boolean
          description: 'Whether this project is a GitLab CI/CD catalog resource, as cached by the sync on each sweep (never a live provider call). Reset to false when a later sweep no longer lists it; left untouched when the listing fails. Independent of official_component_repo.'
        official_component_repo:
          type: boolean
          description: 'Whether a Maintainer has selected this project as the org''s official component repository via PUT /projects/{id}/official-component. Independent of is_component_repo: a Maintainer may curate a project as official regardless of the sync''s detection, and the two flags are never derived from one another.'
        last_onboarding_error:
          type: string
          description: 'The diagnostic from the project''s most recent onboarding attempt when that attempt failed. Empty string (never a literal null) when there is no unresolved onboarding failure: never onboarded, or a later attempt superseded the failure. An accepted-then-failed onboarding is readable here with its stored error.'
        integration_method:
          type: string
          enum: ['', ci_file, ci_config_path]
          description: 'How the project was integrated: ci_file, a merge request added the component include to its CI file; ci_config_path, its CI configuration path setting points at the instance''s central file; empty for a project integrated before this distinction existed or detected without a platform onboarding.'
        last_trigger:
          allOf: [ { $ref: '#/components/schemas/InventoryLastTrigger' } ]
          nullable: true
          description: 'What the last re-check attempt on this project concluded, which POST /projects/{project_path}/recheck cannot report itself since it answers 202 before the provider is called. Omitted from the body (never a literal null) when no re-check attempt has ever been recorded; check for the key''s presence. A re-check that failed against the provider (a denied retry, an unreachable instance) records nothing, so this is the last attempt that reached a conclusion, not necessarily the last attempt made; read `at` to tell the two apart.'
        pending_onboarding:
          allOf: [ { $ref: '#/components/schemas/InventoryPendingOnboarding' } ]
          nullable: true
          description: 'The project''s live onboarding attempt (status pending or mr_opened; at most one per project). Omitted from the body (never a literal null) when there is none: never onboarded, or the most recent attempt already resolved to merged or failed. A failed attempt''s diagnostic is last_onboarding_error, not here. Check for the key''s presence.'
        pending_unboarding:
          allOf: [ { $ref: '#/components/schemas/InventoryPendingUnboarding' } ]
          nullable: true
          description: 'The project''s live unboarding attempt (status pending or mr_opened; at most one per project), the reverse of pending_onboarding. Omitted from the body (never a literal null) when there is none: never unboarded, or the most recent attempt already resolved to merged or failed. Its id is what POST /projects/unboard/merge and POST /projects/unboard/cancel take. Check for the key''s presence.'
        policy_count:
          type: integer
          description: 'How many policies govern this project: always exactly policy_ids.length (see policy_ids for the membership branches and the scope filter). Compare PolicySummary.assigned_project_count, which counts the same governance from the policy''s side.'
        portfolio_count:
          type: integer
          description: 'How many portfolios this project resolves as a member of: explicit membership plus every portfolio whose group rule (prefix match) matches this project''s path, deduplicated.'
        policy_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Every policy that governs this project: its direct assignments, every policy attached to a portfolio it resolves into (explicit membership and group rules), and every applies_to=all policy of the org whose scope it falls within, whatever its deployment status, unless it is archived or provider-deleted; each narrowed by that policy''s scope_filter, deduplicated, ordered by policy id. Exactly the set the policy_id filter selects on and the pipeline gate resolves. policy_count is this array''s length. Empty array allowed, never omitted.'
        portfolio_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'The ids behind portfolio_count (explicit membership plus matching group rules), deduplicated. Empty array allowed, never omitted.'
        open_issue_count:
          type: integer
          description: 'How many of this project''s issues are open (status Detected or In progress). Always present, 0 allowed.'
        last_run_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the most recent run for this project was received (the newest accepted push, regardless of its freshness or kind), distinct from score_letter/score_points, which track the last scored state. Omitted from the body (never a literal null) when the project has never received a push; check for the key''s presence.'
        last_activity_at:
          type: string
          format: date-time
          nullable: true
          description: 'The project''s most recent activity timestamp as reported by the provider''s project listing. Omitted from the body (never a literal null) when not collected yet (synced before this field existed, or the listing carried no value); fills in on the next sync. Check for the key''s presence.'
        provider_created_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the project was created on its git provider, as reported by the provider''s listing; not the date the platform first discovered it. Omitted from the body (never a literal null) when the sync has not yet recorded it (synced before this field existed, or the listing carried no value); fills in on the next sync. Check for the key''s presence. The created_since/created_before filters on listProjects bind this value and exclude a row that does not carry it.'
        languages:
          type: object
          additionalProperties: { type: number }
          nullable: true
          description: 'A {language name: percentage share} map, collected by the snapshot refresh. Omitted from the body (never a literal null, never an empty placeholder) when not collected yet; a failed fetch never clears a previously collected value. Check for the key''s presence.'
    InventoryPendingOnboarding:
      type: object
      description: 'The shape of InventoryProject.pending_onboarding when present: one project''s in-flight onboarding attempt.'
      required: [id, status, resolved_component_ref, pipeline_has_plumber_job, warnings]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, mr_opened]
          description: 'Only the two live onboarding statuses appear here; a merged, applied or failed attempt resolves InventoryProject.pending_onboarding to null instead.'
        mr_url:
          type: string
          nullable: true
          description: 'The opened merge request''s web URL, present once status is mr_opened; omitted from the body while status is pending (never an empty string).'
        resolved_component_ref:
          type: string
          description: 'The component ref this onboarding merge request pinned when the instance is in resolved mode (InstanceSettings.component_resolution `resolved`: the platform picks the component repository''s newest immutable tag at MR time). Always present; an empty string means either the instance is pinned (the ref is the instance''s own component_ref, readable from the settings) or the merge request has not been opened yet (status pending). A stored fact, never re-derived on read.'
        pipeline_has_plumber_job:
          type: string
          enum: [unknown, present, absent]
          description: 'Whether the open onboarding merge request''s latest pipeline carries the `plumber` job, as read once per sync (never a live provider read): `present`, `absent`, or `unknown` until the first sync after the request opened or while no pipeline exists for it.'
        warnings:
          type: array
          items: { $ref: '#/components/schemas/OnboardWarningReason' }
          description: 'The non-blocking notes recorded for this attempt during onboarding: only `stage_input_undeclared` and `no_plumber_job` appear here (OnboardAccepted.warnings, computed at request time, never carries these two). Always present, never null; empty when there is nothing to warn about or the attempt has not reached that point yet. A warning never means the attempt failed. Re-decided from scratch by a retried attempt.'
    InventoryLastTrigger:
      type: object
      description: 'The shape of InventoryProject.last_trigger when present: what the last re-check attempt on one project concluded.'
      required: [outcome, detail, at]
      properties:
        outcome:
          type: string
          enum: [retried, no_prior_run]
          description: '`retried`: the provider accepted the retry of the project''s last Plumber CI job and the run is on its way (freshness moves to `running`, then to its real state, over SSE as usual). `no_prior_run`: no Plumber job was found to retry on the default branch within the searched window, so nothing was started and nothing changed. The list is closed; a re-check that failed against the provider records nothing (see InventoryProject.last_trigger).'
        detail:
          type: string
          description: 'A one-line sentence for the outcome: for `no_prior_run`, that no job named plumber was found on the default branch within the searched window of the job listing; for `retried`, the pipeline the retry landed on. Platform text, never a provider echo and never a raw error. Display it as-is; do not parse it.'
        at:
          type: string
          format: date-time
          description: 'When the outcome was recorded (server time). Tells a re-check just requested from one from last week, and is the only way to tell that a newer attempt failed before reaching a conclusion (the stored outcome then still describes the older one).'
    InventoryPendingUnboarding:
      type: object
      description: 'The shape of InventoryProject.pending_unboarding when present: one project''s in-flight unboarding attempt, the reverse of InventoryPendingOnboarding.'
      required: [id, status]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, mr_opened]
          description: 'Only the two live unboarding statuses appear here; a merged or failed attempt resolves InventoryProject.pending_unboarding to null instead.'
        mr_url:
          type: string
          nullable: true
          description: 'The opened removal merge request''s web URL, present once status is mr_opened; omitted from the body while status is pending (never an empty string).'
    InventoryList:
      type: object
      description: 'The listProjects response envelope: one keyset page of InventoryProject rows plus pagination metadata.'
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/InventoryProject' }
        total: { type: integer, description: 'Total visible rows matching every filter, across every page; identical for every page of the same walk.' }
        next_cursor:
          type: string
          nullable: true
          description: 'Opaque cursor for the next page''s `after` parameter; null when this is the last page.'
    ProjectsTree:
      type: object
      description: 'The listProjectsTree response envelope: one level of the group tree, its direct subgroups (with descendant counts) and one keyset page of its direct child projects. Both halves are filtered by the caller''s own provider visibility; see the operation description.'
      required: [groups, projects, total, groups_truncated]
      properties:
        groups:
          type: array
          items: { $ref: '#/components/schemas/ProjectsTreeGroup' }
          description: 'The direct subgroups of parent_path, ordered by groups_sort. Empty when this level has no subgroup that any project (under the active filters) sits below.'
        projects:
          type: array
          items: { $ref: '#/components/schemas/InventoryProject' }
          description: 'One keyset page of the direct child projects of parent_path: the same InventoryProject rows listProjects serves, visibility-filtered the same way.'
        total: { type: integer, description: 'Total visible direct child projects matching every filter, across every page of the projects half; identical for every page of the same walk. Not the sum of the group counts above, which count descendants at every depth.' }
        next_cursor:
          type: string
          nullable: true
          description: 'Opaque cursor for the projects half''s next page (`after`); null when this is its last page. The groups half is never paginated.'
        groups_truncated:
          type: boolean
          description: 'True when this level has more direct subgroups than one response returns (500): the groups listed are the first 500 under the requested groups_sort. False in every ordinary case.'
    ProjectsTreeGroup:
      type: object
      description: 'One direct subgroup in a listProjectsTree level, derived from project paths (never from a separate tree structure): a segment with no project anywhere under it is not a node at all.'
      required: [path, name, project_count]
      properties:
        path: { type: string, description: 'The group''s own full path (parent_path plus one segment; the bare segment at the root level).' }
        name: { type: string, description: 'The provider''s own group name as last synced (it can differ from the path: a group named "Engineering Team" can sit at path "eng"), falling back to the path''s last segment where no synced name is available yet. Never an empty string; "as last synced" staleness applies, as for listSyncedGroups.' }
        project_count:
          type: integer
          description: 'Every project anywhere below this group (a descendant count, not a direct-child count) that the caller can see, counted under the request''s own filters. A group checkbox selects exactly this set; a subgroup whose every project is invisible to the caller is not listed at all.'
    ProjectBranchList:
      type: object
      description: 'The listProjectBranches response envelope: one offset-paginated page of ProjectBranch rows plus pagination metadata.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProjectBranch' }
        total:
          type: integer
          description: 'Every live branch (see the operation description''s 30-day window) for this project, counted before the 200-branch cap: total can exceed 200 while at most 200 rows (the worst 200) are ever reachable across every page.'
        page: { type: integer }
        per_page: { type: integer }
    ProjectBranch:
      type: object
      description: 'One row of listProjectBranches: one live branch''s own newest run.'
      required: [branch, score_letter, score_points, scorable, gate_blocked, last_run_at]
      properties:
        branch:
          type: string
          description: 'The branch name stamped on the run at ingest (verified against the project''s default branch at that time), never the push body''s own unverified claim.'
        score_letter: { type: string, description: 'This branch''s own latest run''s score_letter. Empty string when scorable is false, never a fabricated grade. Independent of which branch owns this project''s displayed score (InventoryProject.score_letter, labeled by score_source): this row can carry a worse or better letter than the displayed one.' }
        score_points: { type: integer, description: 'This branch''s own latest run''s score_points. 0 when scorable is false; see score_letter for the display-independence notes shared by this pair.' }
        scorable: { type: boolean, description: 'Whether this branch''s latest run had at least one scorable policy. False means score_letter/score_points carry no meaning ("" and 0 respectively), the per-branch form of InventoryProject.newest_run_unscorable.' }
        gate_blocked:
          type: [boolean, 'null']
          description: 'Whether this branch''s latest run''s gate blocked (the per-policy block|report enforcement dial). A real JSON null when the run carries no recorded gate outcome, never a fabricated false.'
        last_run_at:
          type: string
          format: date-time
          description: 'When this branch''s own newest run was received. Never null or omitted here, since every row has at least one run (contrast InventoryProject.last_run_at, which is project-wide and can be absent).'
    Overview:
      type: object
      description: 'The coverage rollup over the projects the caller can see (see getOverview). Derived on read, never stored.'
      required: [projects_total, covered, uncovered, stale, outdated, adoption_percent, score_points, score_letter]
      properties:
        projects_total: { type: integer, description: 'Visible, non-archived, non-provider-deleted projects.' }
        covered: { type: integer }
        uncovered: { type: integer }
        stale: { type: integer }
        outdated: { type: integer, description: 'Onboarded projects reporting a current score (fresh/running) whose deployed Plumber component version is behind. Still counted toward adoption_percent; see its description.' }
        adoption_percent:
          type: number
          format: float
          description: '(covered + outdated) / projects_total * 100, one decimal; 0 when projects_total is 0. An outdated project is still onboarded and reporting a current score, so it counts as adopted: only the component version lags.'
        score_points:
          type: number
          format: float
          description: 'The org-wide average of the latest run points (final points) across every project in the org that has a run, rounded half-up to one decimal. Unlike the coverage counts above, this figure is org-wide across every instance and not filtered by the caller''s visibility. 0 when no project in the org has ever completed a scored run; read score_letter to tell unscored from a real 0. A project whose display-owning run is unscorable (a degraded collection, every policy result withholding its score, or nothing evaluated) counts in neither the numerator nor the denominator, so an org of five projects scored 90 and 50 with three unscorable reads 70, and an org whose every project is unscorable is unscored.'
        score_letter:
          type: string
          description: 'Grade A-E, derived from score_points via the CLI''s published scoring letter bands (the same derivation as ScoreHistoryItem.score_letter, PortfolioDetail.score_letter and PolicySummary.score_letter). Empty string (with score_points 0) when the org is unscored, which includes an org whose every project''s display-owning run is unscorable; never a literal null.'
    TopProject:
      type: object
      description: 'One row of TopProjects.items: a slim, score-ranked projection, not a full InventoryProject.'
      required: [id, path, name, name_with_namespace, score_points, score_letter, score_degraded]
      properties:
        id: { type: string, format: uuid }
        path: { type: string }
        name: { type: string, description: 'The project''s display name, the same provider-listing value InventoryProject.name carries; "" until the row''s next sync.' }
        name_with_namespace: { type: string, description: 'The project''s full display path, the same provider-listing value InventoryProject.name_with_namespace carries; "" until the row''s next sync.' }
        score_points: { type: integer, description: 'The project''s own last completed run score (final points), the same value InventoryProject.score_points carries for this project.' }
        score_letter: { type: string, description: 'Grade A-E, derived from score_points the same way ScoreHistoryItem.score_letter is; never empty here, since TopProjects only lists projects that have a run (see topProjects).' }
        score_degraded: { type: boolean, description: 'True when the run owning the displayed score carried collection_degraded on push, the same field and display-ownership rule as InventoryProject.score_degraded; an out-of-order push never flips it. Aggregates carry no such flag.' }
    TopProjects:
      type: object
      description: 'The topProjects response envelope: the caller''s top-scoring visible projects, ordered score_points descending then project_path ascending, capped at `limit`. Not the standard list envelope: bounded by `limit` alone, never paginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/TopProject' }
          description: 'Ordered score_points DESC, project_path ASC (the tiebreak). At most `limit` entries. Empty when the caller has no visible project with a run.'
    ConfigEnforcement:
      type: string
      enum: [block, report]
      description: 'Enforcement dial: `block` or `report`, the same vocabulary as `Policy.enforcement`. There is no `fail_mode`: degraded controls are single and non-configurable.'
    CreatedID:
      type: object
      description: 'The 201 body for every config create endpoint.'
      required: [id]
      properties:
        id: { type: string, format: uuid }
    PolicySummary:
      type: object
      description: 'One row of the policies list: metadata and counts only, never the full control groups/controls (see PolicyDetail).'
      required: [id, name, color, enforcement, control_group_count, control_count, assigned_project_count, assigned_portfolio_count, governed_project_count, score_points, score_letter, scope_filtered, applies_to]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        color: { type: string }
        enforcement: { $ref: '#/components/schemas/ConfigEnforcement' }
        min_points:
          type: integer
          nullable: true
          description: 'The per-policy enforcement threshold; see PolicyMetaRequest.min_points for the write-side semantics. Null or absent means unset (the any-fail gate behavior).'
        applies_to:
          type: string
          enum: [assigned, all, all_onboarded]
          description: 'See PolicyMetaRequest.applies_to. The canonical value is `all`; `all_onboarded` is accepted on write as a deprecated alias for one release and is always served as `all`, so a read never returns it. Always present.'
        control_group_count:
          type: integer
          description: 'How many control groups the policy has (control groups only, never controls).'
        control_count: { type: integer, description: 'Total controls across all control groups.' }
        assigned_project_count:
          type: integer
          description: 'Direct project assignments, not counting projects reached only via a portfolio (see PolicyDetail.assignments.portfolio_reach_count for that). Always present, never null.'
        assigned_portfolio_count:
          type: integer
          description: 'Direct portfolio assignments. Always present, never null.'
        governed_project_count:
          type: integer
          description: 'The size of the policy''s governed set: direct assignments, portfolio reach and, under applies_to=all, every live in-scope project of the org; the same set the score and the Assessed projects count use. Not cut to the caller''s visibility; freshness_summary.total on the detail is the visibility-cut twin. A project reached only through a portfolio group rule on a second instance is not counted.'
        score_points:
          type: number
          format: float
          description: 'The policy''s current score (final points), the same live compute as PortfolioDetail.score_points and the live-today point of GET /config/policies/{id}/score-history: the average restricted to the policy''s own governed set (the same population PolicyDetail.freshness_summary and the policy report''s Assessed projects use), never every project whose latest run happens to carry a result for this policy. 0 when the policy is unscored (no project of its governed set has ever completed a scored run under it); read score_letter to tell unscored from a real 0.'
        score_letter:
          type: string
          description: 'Grade A-E, derived from score_points via the CLI''s published scoring letter bands (the same derivation as ScoreHistoryItem.score_letter and PortfolioDetail.score_letter). Empty string when the policy is unscored (score_points is also 0 then, so this field is what distinguishes "unscored" from "scored at exactly 0"); never a literal null.'
        scope_filtered:
          type: boolean
          description: 'True when this policy carries a saved scope_filter (the list-row badge; see PolicyDetail.scope_filter for the full payload). Always present, never null.'
    PolicyList:
      type: object
      description: 'The listPolicies response envelope: one offset-paginated page of PolicySummary rows, alphabetical by name.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PolicySummary' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    PolicyMetaRequest:
      type: object
      description: 'Request body for both createPolicy (POST) and updatePolicyMeta (PUT); the same fields either way.'
      required: [name, enforcement]
      properties:
        name:
          type: string
          maxLength: 255
          description: 'Mandatory, non-empty, at most 255 bytes (the cap is measured in bytes, not characters, so a multi-byte name reaches it sooner than its character count suggests). Unicode control, format and line-separator characters are rejected with a 400 naming the code point; markdown or HTML-shaped text is otherwise unrestricted.'
        description: { type: string }
        color: { type: string, pattern: '^(|#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6})$', description: 'Optional; server default #3b82f6 applied when omitted or empty. When non-empty, must be #RGB or #RRGGBB hex.' }
        enforcement: { $ref: '#/components/schemas/ConfigEnforcement' }
        min_points:
          type: integer
          nullable: true
          description: 'The per-policy enforcement threshold, optional and nullable, on the same scale as score_points (final points). When set, a block-mode policy blocks when its pushed final points are below this value; when unset, omitted or explicit null, the gate keeps the any-fail behavior. A report-mode policy never blocks either way. A negative value is rejected with a 400 naming the field. Never carried in .plumber.yaml; served to the CLI on /context as Policy.min_points.'
        scope_filter:
          allOf: [{ $ref: '#/components/schemas/PolicyScopeFilter' }]
          nullable: true
          description: 'The policy''s optional scope filter. Absent or explicit null clears it entirely (full replace; unlike min_points, absent and explicit null both mean "no filter"). Validated identically on createPolicy and updatePolicyMeta; see PolicyScopeFilter for the caps and the match_operator rule (400 naming the field on any violation).'
        applies_to:
          type: string
          enum: [assigned, all, all_onboarded]
          description: 'What the policy governs. `assigned` (the default on create): its direct project assignments plus every project reached through its assigned portfolios. `all`: every live project of the org whatever its deployment status, archived and provider-deleted projects excluded, assigned or not (coverage, the stale and no-result counts and licence seats still count onboarded projects only). The scope filter narrows both. Assignment rows are kept and stay writable under `all`. `all_onboarded` is accepted on write as a deprecated alias for `all` for one release and is always served as `all`. On PUT this field is the one exception to full replace: absent or null keeps the stored value, present sets it. A change is a membership change: projects entering or leaving the governed set go stale, and a flip back to `assigned` closes the issues of every project that thereby loses the policy.'
    PolicyScopeFilter:
      type: object
      description: |-
        The optional per-policy topic/technology scope filter. It narrows the policy's whole effective scope at read time, direct assignments and portfolio-contributed projects alike: a directly assigned project that fails the filter leaves the scope, exactly like a portfolio-contributed one.

        Matching is case-insensitive on both topics and language names. An empty or absent condition kind (topics, language_conditions) drops out of the combination and is never treated as false, so a topics-only filter behaves the same whether match_operator is any or all. match_operator is required if and only if both kinds are non-empty, and refused with a 400 naming the field when at most one kind is present, on the policy write and on previewPolicyScope alike.

        A project whose filter-referenced attribute has never been collected (topics or languages never collected, as opposed to empty) never matches and is counted by unclassified_count rather than folded into a real exclusion; see PolicyDetail.unclassified_count and PolicyScopePreviewResponse.unclassified_count.

        Absent or explicit null on a policy write clears the filter entirely (PolicyMetaRequest.scope_filter, full replace); the policy's scope then resolves exactly like an unfiltered policy.
      properties:
        topics:
          type: array
          items: { type: string }
          maxItems: 50
          description: 'Topic strings as typed (stored verbatim; matched case-insensitively against the project''s collected GitLab topics). At most 50 entries, each non-empty after trimming whitespace (400 otherwise). An absent or empty array means this condition kind drops out of the combination, never treated as false.'
        language_conditions:
          type: array
          items: { $ref: '#/components/schemas/PolicyScopeLanguageCondition' }
          maxItems: 50
          description: 'Per-language share conditions. At most 50 entries. An absent or empty array means this condition kind drops out of the combination (never treated as false).'
        match_operator:
          type: string
          enum: [any, all]
          description: 'Required if and only if both topics and language_conditions are non-empty: any = OR across the two kinds, all = AND. Refused with a 400 naming the field when at most one kind is non-empty.'
    PolicyScopeLanguageCondition:
      type: object
      description: 'One entry of PolicyScopeFilter.language_conditions: matches a project whose collected language map (the GitLab {language: percent} shape) has an entry for `name`, matched case-insensitively, at or above min_share.'
      required: [name]
      properties:
        name: { type: string, description: 'Language name, matched case-insensitively against the project''s collected language map. Must be non-empty after trimming whitespace (400 otherwise).' }
        min_share:
          type: number
          format: float
          minimum: 0
          maximum: 100
          description: 'Minimum share of the project''s codebase this language must occupy, as a percentage (0-100). Absent means "this language, any share".'
    FreshnessSummary:
      type: object
      description: |-
        A freshness rollup, present on both PolicyDetail and PortfolioDetail with the same semantics. On PolicyDetail it covers the policy's governed set: its direct project assignments plus every project reached through its assigned portfolios (direct members and group rules), or, when applies_to is all, every project of the org within its scope filter whatever its deployment status; narrowed by its scope filter, the same population its governed projects page and report use. On PortfolioDetail it covers the portfolio's resolved members (the same set resolved_projects/resolved_count resolve).

        (a) Every count is restricted to the projects the caller can see, as Overview is; archived and provider_deleted projects are excluded.

        (b) `stale` here is the freshness-only count of onboarded projects whose latest run has aged past the freshness window, distinct from `Overview.stale` on the same PortfolioDetail body (via `aggregate`), which is a coverage bucket that also folds in projects reporting no result received. The two numbers answer different questions.

        (c) `stale` and `no_result_received` count only onboarded projects: a never-onboarded project has no score to be stale or missing. `total` counts every scoped visible project regardless of onboarding, so `stale` + `no_result_received` does not sum to `total`.
      required: [total, stale, no_result_received]
      properties:
        total: { type: integer, description: 'Every scoped, visible, non-archived, non-provider-deleted project, onboarded or not (see the schema description, point (c)).' }
        stale: { type: integer, description: 'Onboarded projects whose latest run has aged past the freshness window (vocabulary: fresh/stale/running/no result received). Never includes a never-onboarded project. Distinct from Overview.stale; see the schema description, point (b).' }
        no_result_received: { type: integer, description: 'Onboarded projects that have never reported a run result. Never includes a never-onboarded project; see the schema description, point (c).' }
    PolicyDetail:
      type: object
      description: 'The full getPolicy body: metadata, the control groups/controls tree, and a preview of the resolved assignment id sets (see PolicyAssignmentsPreview and GET /config/policies/{id}/projects for the rest).'
      required: [id, name, color, enforcement, control_groups, assignments, freshness_summary, applies_to]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        color: { type: string }
        enforcement: { $ref: '#/components/schemas/ConfigEnforcement' }
        min_points:
          type: integer
          nullable: true
          description: 'The per-policy enforcement threshold PolicyMetaRequest.min_points writes; see that field for the full semantics. Null when unset.'
        applies_to:
          type: string
          enum: [assigned, all, all_onboarded]
          description: 'See PolicyMetaRequest.applies_to. The canonical value is `all`; `all_onboarded` is accepted on write as a deprecated alias for one release and is always served as `all`, so a read never returns it. Always present.'
        scope_filter:
          allOf: [{ $ref: '#/components/schemas/PolicyScopeFilter' }]
          nullable: true
          description: 'The policy''s saved scope filter, or null when unset; the same value PolicyMetaRequest.scope_filter writes. See PolicySummary.scope_filtered for the cheaper list-row badge, and previewPolicyScope for previewing an unsaved filter before writing it.'
        unclassified_count:
          type: integer
          description: 'Present only when scope_filter is set (omitted entirely, never a literal 0, when the policy carries no filter): the number of candidate projects (the pre-filter direct-assignment-plus-portfolio union) the filter excluded because at least one filter-referenced attribute has never been collected, rather than an actual mismatch. A project excluded on real collected values, or one that matched via the other condition under match_operator any, is never counted here.'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        control_groups:
          type: array
          description: 'The policy''s control groups, each with its controls.'
          items: { $ref: '#/components/schemas/ControlGroupDetail' }
        assignments: { $ref: '#/components/schemas/PolicyAssignmentsPreview' }
        freshness_summary:
          $ref: '#/components/schemas/FreshnessSummary'
          description: 'The freshness rollup over the policy''s governed set (see FreshnessSummary for the population and the three caveats): total grows with the governed set, while stale, no_result_received, coverage and licence seats still count onboarded projects only.'
    ControlGroupDetail:
      type: object
      description: 'One control group as read back, carrying its stable id (contrast ControlGroupInput, which never does: position is array order, not a caller id).'
      required: [id, name, controls]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        controls:
          type: array
          items: { $ref: '#/components/schemas/ControlDetail' }
    ControlDetail:
      type: object
      description: 'One control instance as read back.'
      required: [id, type, version, config]
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        version: { type: string }
        config:
          type: object
          additionalProperties: true
          description: 'The control''s stored configuration, served byte-for-byte as stored: never redacted, summarized, re-encoded or shared across policies, so an integer literal above 2^53 and the stored key order are preserved. An arbitrary JSON object, not a closed shape.'
          x-go-type: json.RawMessage
    PolicyContentRequest:
      type: object
      description: 'Body of replacePolicyContent (PUT .../content): the complete desired control groups/controls tree, never a diff. An unrecognized field anywhere in this body, including inside a nested control group or control, is rejected with a 400 naming the field, with one exception: ControlGroupInput.id and ControlInput.id are accepted and ignored so a getPolicy response can be edited and PUT back verbatim. The tree key is `control_groups`; a body still posting `requirements` gets a 400 naming `control_groups` absent.'
      required: [control_groups]
      properties:
        control_groups:
          type: array
          items: { $ref: '#/components/schemas/ControlGroupInput' }
    ControlGroupInput:
      type: object
      description: 'One control group in a content replace. Position is derived from array order, never from a caller-supplied id (see id for why one is accepted anyway).'
      required: [name, controls]
      properties:
        id: { type: string, format: uuid, description: 'Accepted and ignored: present so a getPolicy response (ControlGroupDetail.id) can be round-tripped verbatim on save; control group identity always comes from array position. Never required, never validated, never read.' }
        name: { type: string, description: 'Mandatory, non-empty.' }
        description: { type: string }
        controls:
          type: array
          items: { $ref: '#/components/schemas/ControlInput' }
    ControlInput:
      type: object
      description: 'One control instance in a content replace. Weights and points are not included here; they live in the versioned control library.'
      required: [type, version, config]
      properties:
        id: { type: string, format: uuid, description: 'Accepted and ignored: present so a getPolicy response (ControlDetail.id) can be round-tripped verbatim on save; control identity always comes from array position. Never required, never validated, never read.' }
        type:
          type: string
          description: 'Control type identifier. Existence is not validated: a type the pinned CLI does not serve on `GET /controls` (a newer CLI''s control) is accepted and stored as-is with no config validation. One kind is neither validated nor stored: a control the pinned CLI declares for no provider this platform serves (the platform is GitLab-only) is dropped from the stored content, never a 400, and reported in the 200 body as a `provider_unsupported` warning; the rest of the tree is written normally.'
        version: { type: string }
        config:
          type: object
          additionalProperties: true
          description: 'The control''s configuration, stored byte-for-byte (an integer literal above 2^53 and the key order are preserved). When `type` is a control served on `GET /controls`, this object is validated at write time against the served schema: unknown keys, field types, enum values, an explicit null on a non-nullable field, and an integer literal beyond int64 are each reported as a ConfigValidationError. An omitted key is never a violation (requiredness and defaults stay the CLI''s own), so `{}` is always acceptable for a known control. When `type` is not served, nothing is validated (see `type`). An arbitrary JSON object, not a closed shape.'
          x-go-type: json.RawMessage
    ConfigValidationError:
      type: object
      description: 'One refused thing in a control config, as carried by the `violations` array of replacePolicyContent''s 400 body (see that response for the envelope): a field path and a reason from a closed set, never a prose sentence, so a client can attach the error to the input that produced it.'
      required: [field_path, reason]
      properties:
        field_path:
          type: string
          description: 'The full dotted path of the offending value from the request body''s root, e.g. `control_groups[0].controls[2].config.cacheActions[0].mode`; array positions are `[i]`, the actual position in the submitted array. Every path points inside one control''s `config`, never at the control''s own `type` (a type the platform does not serve is stored unvalidated; see ControlInput.type).'
        reason:
          type: string
          enum: [unknown_key, type_mismatch, enum_miss, null_not_allowed, integer_out_of_range]
          description: |-
            Why, from a closed set:
            - `unknown_key`: a key the served schema does not declare for that object.
            - `type_mismatch`: the value's JSON type is not the field's declared `type`; a `config` that is not a JSON object at all, and a non-integer number (1.5, 1e2) on an `integer` field, are both included: a float is not an integer.
            - `enum_miss`: a string outside the field's served `enum`. Matching is exact, never case-folded.
            - `null_not_allowed`: an explicit `null` on a field the served schema does not mark `nullable` (`optional` is a different property: it says the key may be omitted, not that its value may be null).
            - `integer_out_of_range`: an integer literal beyond int64, the right type at a magnitude the CLI cannot read back. A fraction or exponent form is `type_mismatch` instead, and the boundary is int64's, not 2^53: 2^53 + 1 is accepted and carried faithfully.
    PolicyContentResult:
      type: object
      description: 'The body of a successful replacePolicyContent. The write already happened; `warnings` is advisory only and always present, empty when there is nothing to say. One warning reason, `provider_unsupported`, additionally reports that an entry was dropped rather than stored: re-read the policy to see the tree that was actually written.'
      required: [warnings, warnings_total]
      properties:
        warnings:
          type: array
          description: 'Every tolerated disagreement and every dropped entry, in body order then served-schema order within a control. Empty when the whole tree matched the served schema and named only controls this platform serves. Capped at 100 entries.'
          items: { $ref: '#/components/schemas/ConfigContentWarning' }
        warnings_total:
          type: integer
          description: 'The true, uncapped number of tolerated disagreements, always present (0 when there are none). As with `violations_total` on the 400 body, the array above is capped at 100, so this is how a client tells a truncated list from a complete one.'
    ConfigContentWarning:
      type: object
      description: 'One accepted-but-noteworthy thing in a control config. A separate schema from ConfigValidationError, whose `reason` vocabulary is reserved for refusals, so a client can always tell a 400 reason from a 200 one.'
      required: [control, field_path, reason]
      properties:
        control: { type: string, description: 'The control type the key belongs to, as submitted.' }
        field_path: { type: string, description: 'The path of the tolerated key inside that control''s `config` (e.g. `cacheActions[0].mode`), never the request-body path; `control` already says which control it belongs to. Empty when the warning is about the control itself rather than one key of its configuration, which today means exactly `reason: provider_unsupported`: the whole entry was dropped, so there is no offending key to point at.' }
        reason:
          type: string
          enum: [unknown_key_tolerated, provider_unsupported]
          description: |-
            Why, from a closed set:
            - `unknown_key_tolerated`: the served schema does not declare this key, and the policy's stored configuration for that control already carried the same key, and that stored row was written under a different CLI pin than this build serves. The key was kept exactly as submitted. A key unknown on a configuration that does not predate the pin (a brand-new key, or one stored under this very pin) is still a 400 `unknown_key` violation.
            - `provider_unsupported`: the pinned CLI declares this control for providers this platform does not serve (this platform is GitLab-only); the entry was dropped from the stored content, the rest of the write applied. `field_path` is empty. Such a control is not listed by `GET /controls` either, so a console built on the served library never offers one; the drop exists for a body written by hand, restored from an export, or carried over from a policy stored before this rule.
            The toleration is not one-shot: a control whose write carried at least one tolerated key keeps the CLI pin its stored row already recorded, so the same tree saves again the same way, warning and all, for as long as the key is there. Removing the key ends it: that write tolerates nothing, so the control is stamped with the pin this build serves like any other write.
    ControlImportDrop:
      type: object
      description: 'One control the onboarding `.plumber.yaml` import skipped, for one of two reasons, judged in that order. Its configuration disagrees with the control schema this platform serves (`GET /controls`), the same validation `PUT /config/policies/{id}/content` applies, and then `reason` is absent and `violations` says what was wrong. Or the control is one this platform does not serve at all, and then `reason` is `provider_unsupported` and `violations` is empty. The rest of the file still imports; a control type the served catalog does not carry is never judged and is imported as-is. The accepted item also carries the `plumber_yaml_controls_skipped` warning reason whenever this array is non-empty.'
      required: [control, violations]
      properties:
        control: { type: string, description: 'The control key as the file names it (the same identifier `GET /controls` serves as `type`).' }
        reason:
          type: string
          enum: [provider_unsupported]
          description: 'Present only when the control was skipped for a reason other than a configuration disagreement, which today means exactly `provider_unsupported`: the pinned CLI declares this control for providers this platform does not serve (this platform is GitLab-only), so it is never imported into a policy whatever the file configures. `violations` is then empty: nothing was wrong with the configuration. Absent for a configuration skip, whose reasons are the `violations` entries themselves; a control whose configuration was refused is reported that way and never re-reported here.'
        violations:
          type: array
          description: 'Every disagreement found in that control''s configuration, in the served schema''s own field order then unknown keys sorted, capped at 20 entries per control. `field_path` is relative to the control''s own configuration block (there is no request body to anchor it to). Empty when `reason` is present: that skip is about the control, not its configuration.'
          items: { $ref: '#/components/schemas/ConfigValidationError' }
    ControlLibraryField:
      type: object
      description: 'One typed configuration field of a ControlLibraryEntry, as the pinned CLI''s own config schema declares it.'
      required: [type, optional]
      properties:
        name: { type: string, description: 'The field''s yaml key inside the control''s config block, e.g. "namePatterns" on branchMustBeProtected. Present on every named field; absent on an `elem` node, because an array element shape has no key of its own (never an empty-string stand-in).' }
        type:
          type: string
          enum: [string, boolean, integer, number, array, object]
          description: 'The field''s JSON type, derived from the pinned CLI schema''s declared field kind (`bool` maps to `boolean`; `integer`/`number`/`string`/`array`/`object` map 1:1).'
        description:
          type: string
          description: 'The field''s own description, exactly as the pinned CLI schema states it. Absent when the pinned CLI exports none for this field, never an empty-string stand-in; some fields carry no description at all, so an editor renders them with their type, enum and tier and no help text.'
        optional:
          type: boolean
          description: 'Whether a control may omit this field entirely and still be valid, exactly as the pinned CLI schema states it. Always present.'
        nullable:
          type: boolean
          description: 'True when unset is meaningful for this field (the pinned CLI declares it as a pointer), independent of optional (which only says the key may be omitted); absent means false.'
        toggle:
          type: boolean
          description: 'True when this boolean is a behavior switch: unset simply takes the documented default (see `default`), so an editor offers on/off and never a third "not checked" state. A `nullable` boolean without this flag is an expectation where unset means "assert nothing about the scanned project", and the three-state widget is correct. Absent means false, never a present `toggle: false`.'
        requires_tier:
          type: string
          enum: [premium, ultimate]
          description: 'The GitLab plan this one field''s expectation needs, for a control that itself works on every plan but whose field asks about a setting only a paid plan exposes. Absent when every plan, Free included, can satisfy the field; never an empty-string stand-in. A control whose whole gate is a plan carries ControlLibraryEntry.requires_tier instead, and nothing here.'
        default:
          type: string
          description: 'The field''s declared default value, exactly as the pinned CLI schema states it; always a string on the wire regardless of this field''s own `type` (e.g. "true" for a boolean field, "0" for an integer field). See ControlLibraryEntry.default_config for the typed reconstruction. Absent when the CLI declares no default for this field.'
        enum:
          type: array
          items: { type: string }
          description: 'The field''s allowed values, exactly as the pinned CLI schema states them. Absent when the CLI declares none.'
        elem:
          allOf: [{ $ref: '#/components/schemas/ControlLibraryField' }]
          description: 'Present only when `type` is `array`: the shape of one array element, itself a full field description (recursively; an array of objects carries its own `fields` here). Absent for a non-array field.'
        fields:
          type: array
          items: { $ref: '#/components/schemas/ControlLibraryField' }
          description: 'Present only when `type` is `object`: the object''s own nested member fields. Absent or empty for a non-object field.'
    ControlLibraryEntry:
      type: object
      description: 'One control the pinned CLI build recognizes: its identity and catalog metadata, human display name, the control-version convention this platform stores content under, and its typed configuration fields, all read from the pinned CLI''s own catalog.'
      required: [type, version, fields, name, id, category, providers, description, requires_config, default_config, issue_codes]
      properties:
        type:
          type: string
          description: 'Control type identifier, the same string ControlInput.type/ControlDetail.type carry.'
        name:
          type: string
          description: 'The control''s human display title, as the pinned CLI''s catalog names it. Empty string when the pinned CLI does not name this type.'
        version: { type: string, description: 'The control-version convention this platform stores content under. Always "1" today.' }
        id: { type: string, description: 'The pinned CLI''s own stable catalog id for this control, `CTRL-nnn`.' }
        category: { type: string, description: 'The pinned CLI''s own category for this control.' }
        providers:
          type: array
          items: { type: string }
          description: 'Which git provider(s) this control applies to, as the pinned CLI''s catalog declares it: "gitlab" and/or "github".'
        description:
          type: string
          description: 'The pinned CLI''s own prose description of the control.'
        requires_config:
          type: boolean
          description: 'True when enabling this control with no further configuration asserts nothing, as the pinned CLI declares it.'
        requires_tier:
          type: string
          enum: [premium, ultimate]
          description: 'The GitLab plan this control needs to assert anything at all, as the pinned CLI catalog declares it. Absent when every plan, Free included, can satisfy the control; never an empty-string stand-in. A control whose gate is one field only carries nothing here: the tier rides on that field instead (ControlLibraryField.requires_tier).'
        default_config:
          type: object
          additionalProperties: true
          description: 'This control''s declared field defaults, keyed by field name and typed by each field''s own declared `type`: a `boolean` field''s "true"/"false" becomes a JSON boolean, an `integer` or `number` field''s digits a JSON number, and a `string` field''s default is carried as-is (ControlLibraryField.default carries each field''s raw string). Top-level fields only. A field with no declared default, a declared default on an `array` or `object` field, or one that fails to parse under its declared type is absent, never included as a raw string. Always present, `{}` when the control declares no defaults.'
        issue_codes:
          type: array
          items: { type: string }
          description: 'The issue type codes (ISSUE-nnn) this control can raise, as the pinned CLI''s catalog declares them.'
        fields:
          type: array
          items: { $ref: '#/components/schemas/ControlLibraryField' }
    ControlLibraryList:
      type: object
      description: 'The listControls response envelope: every control the pinned CLI build recognizes, sorted by type.'
      required: [items, cli_version, catalog_version]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ControlLibraryEntry' }
        cli_version:
          type: string
          description: 'The pinned CLI version (e.g. "v0.6.0"). Empty string when the running binary carries no version record for the CLI.'
        catalog_version:
          type: integer
          description: 'The pinned CLI''s own `catalogVersion` for this control catalog.'
    IssueTypeCatalogEntry:
      type: object
      description: 'One issue type the pinned CLI declares; see listIssueTypes for the join caveat.'
      required: [code, severity, title, description, remediation, control_type]
      properties:
        code: { type: string, description: 'The issue type''s stable identifier, ISSUE-nnn, exactly as the pinned CLI declares it.' }
        severity: { type: string, description: 'The pinned CLI''s own severity label for this issue type.' }
        title: { type: string, description: 'The pinned CLI''s own display title for this issue type.' }
        description: { type: string, description: 'The pinned CLI''s own prose description of the issue.' }
        remediation: { type: string, description: 'The pinned CLI''s own remediation guidance for this issue type.' }
        doc_url:
          type: string
          description: 'A documentation link for this issue type, exactly as the pinned CLI declares it. Absent when the CLI declares none, never an empty-string stand-in.'
        control_type:
          type: string
          description: 'The owning control''s type identifier (the same string ControlLibraryEntry.type/ControlInput.type carry), as the pinned CLI declares it for this issue type. Empty string when the pinned CLI names no control for this issue type.'
    IssueTypeCatalogList:
      type: object
      description: 'The listIssueTypes response envelope: every issue type the pinned CLI build declares, sorted by code.'
      required: [items, cli_version, catalog_version]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/IssueTypeCatalogEntry' }
        cli_version:
          type: string
          description: 'The pinned CLI version (e.g. "v0.6.0"). Empty string when the running binary carries no version record for the CLI; the same rule as ControlLibraryList.cli_version.'
        catalog_version:
          type: integer
          description: 'The pinned CLI''s own `catalogVersion` for this issue-type catalog; the same field ControlLibraryList.catalog_version carries for the control library.'
    PolicyTemplate:
      type: object
      description: 'One entry of the listPolicyTemplates catalog: a fixed key, display name, swatch color, and content tree, the same shape a replacePolicyContent call accepts verbatim.'
      required: [key, name, color, content]
      properties:
        key: { type: string, description: 'Stable catalog identifier: iso27001, secnumcloud, owasp, or blank. Not a database id: the catalog is built into the platform.' }
        name: { type: string, description: 'Display name, e.g. "ISO27001 & ISO27002".' }
        color: { type: string, description: 'The swatch hex this template pre-fills createPolicy''s own color field with.' }
        content: { $ref: '#/components/schemas/PolicyContentRequest' }
    PolicyTemplateList:
      type: object
      description: 'The listPolicyTemplates response envelope: always exactly the four fixed catalog entries, never paginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PolicyTemplate' }
    PolicyAssignments:
      type: object
      description: 'The full-replace body for replacePolicyAssignments: the complete desired project_id/portfolio_id sets, never a diff. Both directions of the diff (entering and leaving) are computed server-side over the projects the caller can see; a project the caller cannot see is not shown on read and keeps its existing assignment untouched through a write, whether or not the body mentions it. Not the read shape (see PolicyAssignmentsPreview, PolicyDetail.assignments): a read-modify-write round trip must not send the capped preview back as the desired set, or assignments past the cap would be dropped.'
      required: [project_ids, portfolio_ids]
      properties:
        project_ids: { type: array, items: { type: string, format: uuid } }
        portfolio_ids: { type: array, items: { type: string, format: uuid } }
    PolicyAssignmentsPreview:
      type: object
      description: 'The assignments field of PolicyDetail on read: a capped preview of the direct project assignment set plus its true count, and the full (uncapped) portfolio assignment set. A separate schema from PolicyAssignments (the replacePolicyAssignments write body): a bounded preview and a complete desired state are different things, and a read-modify-write client must not truncate assignments past the cap on its next write.'
      required: [project_ids, assigned_project_count, portfolio_ids, portfolio_reach_count]
      properties:
        project_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'First page of the policy''s direct assignments currently within its governed scope (a scope filter narrows this list), ordered by (project_path, id), capped at 100. Lists only the projects the caller can currently see, so a different caller may see a different list and its length may be smaller than assigned_project_count. Under applies_to = all this is every live in-scope project of the org, whatever its deployment status, archived and provider-deleted projects excluded. See assigned_project_count for the true total and GET /config/policies/{id}/projects (offset-paginated) for the rest; on an applies_to = all policy do not assume deep offset pages stay cheap.'
        assigned_project_count:
          type: integer
          description: 'Total direct assignments currently within the policy''s governed scope (a scope filter narrows this count), not filtered by caller visibility (see project_ids for how the preview differs). Under applies_to = all this is every live in-scope project of the org, archived and provider-deleted projects excluded. Differs from PolicySummary.assigned_project_count when a scope filter is set: that list-row count is the raw assignment-row count, unaffected by scope, while this one is the governed subset (see PolicySummary.scope_filtered for the badge and PolicyDetail.unclassified_count for how much of the difference is missing data). Portfolio-derived reach is portfolio_reach_count, never folded in here.'
        portfolio_ids: { type: array, items: { type: string, format: uuid }, description: 'Not filtered by caller visibility and not paginated: a portfolio is an organization-level configuration object, not an individual project, and this set stays small.' }
        portfolio_reach_count:
          type: integer
          description: 'How many distinct projects this policy reaches through portfolio_ids'' current resolution (explicit members plus rule matches, deduplicated across portfolios), the same per-portfolio resolution getPortfolio''s resolved_count uses. Not deduplicated against project_ids/assigned_project_count: a project both directly assigned and portfolio-reachable counts in both numbers, since they answer different questions. 0 (never null) when the policy has no portfolio assignments.'
    PolicyScopePreviewRequest:
      type: object
      description: 'Body of previewPolicyScope: the filter to preview, plus which candidate set to preview it against. Exactly one of `policy_id` or the explicit id form (`project_ids`/`portfolio_ids`, either or both) must be supplied; both or neither is a 400 naming the fields.'
      required: [scope_filter]
      properties:
        scope_filter:
          allOf: [{ $ref: '#/components/schemas/PolicyScopeFilter' }]
          nullable: true
          description: 'The filter to preview, not necessarily the policy''s currently saved filter. An explicit null previews the fully unfiltered candidate set (matched_count equals the candidate count, unclassified_count is 0).'
        policy_id:
          type: string
          format: uuid
          description: 'Preview against this policy''s currently saved direct and portfolio assignments (the whole effective scope, both sources unioned). Mutually exclusive with project_ids/portfolio_ids.'
        project_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 500
          description: 'The create-form case: preview against an explicit candidate project set before any policy exists (or independent of one). Mutually exclusive with policy_id. May be combined with portfolio_ids (unioned, as a saved policy''s own direct-plus-portfolio union is).'
        portfolio_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 100
          description: 'The create-form case: preview against the projects these portfolios currently resolve to, unioned with project_ids if both are given. Mutually exclusive with policy_id.'
        applies_to:
          type: string
          enum: [assigned, all, all_onboarded]
          description: 'In the policy_id form, absent previews the policy''s saved applies_to and a value overrides it for the preview only, never saved; in the explicit-ids form, absent means assigned. With `all` the candidate set is every live project of the org, whatever its deployment status, archived and provider-deleted projects excluded, plus whatever policy_id/project_ids/portfolio_ids contribute. `all_onboarded` is accepted as a deprecated alias for one release and is always served as `all`.'
        page: { type: integer, description: 'Minimum 1; values below 1 are clamped up to 1. Defaults to 1.' }
        per_page: { type: integer, description: 'Capped at 100; values above 100 are clamped down. Defaults to 20.' }
    PolicyScopePreviewResponse:
      type: object
      description: 'One page of the projects a policy scope filter would govern, plus the matched, excluded and unclassified counts.'
      required: [items, total, matched_count, excluded_count, unclassified_count, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PolicyScopePreviewItem' }
          description: 'One page of the matched projects, limited to the projects the caller can see. The three counts below are org-level and not filtered by visibility, except that submitted project_ids the caller cannot see are dropped before resolution and are absent from the counts too.'
        total: { type: integer, description: 'Always equal to matched_count; kept for shape consistency with the other paginated envelopes.' }
        matched_count: { type: integer, description: 'Total projects in the candidate set (the policy''s, or the explicit ids'' direct-plus-portfolio union) that the filter matches.' }
        excluded_count: { type: integer, description: 'Candidate set size minus matched_count. Under match_operator any, a candidate matching either condition kind is governed and counted in matched_count, not here. Excluded projects are either real value mismatches or unclassified (see unclassified_count).' }
        unclassified_count: { type: integer, description: 'Of excluded_count, how many were excluded because a filter-referenced attribute was never collected rather than because of an actual mismatch; same semantics as PolicyDetail.unclassified_count.' }
        page: { type: integer }
        per_page: { type: integer }
    PolicyScopePreviewItem:
      type: object
      description: 'One row of PolicyScopePreviewResponse.items: minimal project identity.'
      required: [id, project_path, name]
      properties:
        id: { type: string, format: uuid }
        project_path: { type: string }
        name: { type: string, description: 'The project name as cached from the provider at sync (see InventoryProject.name); empty until the project''s next sync.' }
    PortfolioSummary:
      type: object
      description: 'One row of the portfolios list: metadata and counts only, never the full projects/rules (see PortfolioDetail).'
      required: [id, name, color, project_count, rule_count, resolved_project_count, score_points, score_letter]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        color: { type: string }
        project_count:
          type: integer
          description: 'Number of explicit projects only; see resolved_project_count for the union with rule matches.'
        rule_count: { type: integer, description: 'Number of group path rules.' }
        resolved_project_count:
          type: integer
          description: 'Number of projects the portfolio resolves to: explicit projects plus every project matching a rule, deduplicated, minus excluded_projects. A project deleted on the provider is never counted; an archived project still counts.'
        score_points:
          type: number
          format: float
          description: 'The portfolio''s live score in points, computed the same way as PortfolioDetail.score_points over the projects the caller can see. 0 when the portfolio is unscored; see score_letter to tell that apart from a real 0. A project whose latest run has no scorable policy is excluded from the average, like a project with no run.'
        score_letter:
          type: string
          description: 'Grade A-E derived from score_points using the CLI''s published letter bands. Empty string (never null) when the portfolio is unscored; since score_points is 0 in that case too, this field is what distinguishes "unscored" from "scored at exactly 0".'
    PortfolioList:
      type: object
      description: 'The listPortfolios response envelope: one offset-paginated page of PortfolioSummary rows, alphabetical by name.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PortfolioSummary' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    PortfolioMetaRequest:
      type: object
      description: 'Request body for both createPortfolio (POST) and updatePortfolioMeta (PUT).'
      required: [name]
      properties:
        name:
          type: string
          maxLength: 255
          description: 'Required, non-empty, at most 255 bytes (measured in bytes, not characters). Unicode control, format and line-separator characters are rejected with 400 naming the code point; markdown or HTML-shaped text is otherwise accepted as-is.'
        color: { type: string, description: 'Optional; defaults to #10b981 when omitted or empty. Stored as given, no format check (unlike PolicyMetaRequest.color).' }
    PortfolioCreateRequest:
      type: object
      description: 'Request body of createPortfolio: PortfolioMetaRequest''s fields plus an optional initial policy set. updatePortfolioMeta uses PortfolioMetaRequest; policies are updated with replacePortfolioPolicies.'
      required: [name]
      properties:
        name: { type: string, maxLength: 255, description: 'Same rule as PortfolioMetaRequest.name.' }
        color: { type: string, description: 'Same rule as PortfolioMetaRequest.color.' }
        policy_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 100
          description: 'Policies to assign to the new portfolio, each of the caller''s org; an unknown id fails the whole request with 404 "policy not found" and no portfolio is created. Absent or empty means none.'
    PortfolioPoliciesRequest:
      type: object
      description: 'Body of replacePortfolioPolicies: the complete desired policy set, never a diff. Absent or null policy_ids is a 400; an explicit empty array clears every assignment.'
      required: [policy_ids]
      properties:
        policy_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 100
    ProjectRef:
      type: object
      description: 'Minimal project identity: id plus path.'
      required: [id, path]
      properties:
        id: { type: string, format: uuid }
        path: { type: string }
    ProjectRefList:
      type: object
      description: 'The listProjectIds response envelope: one keyset page of ProjectRef rows plus pagination metadata, the same envelope as InventoryList with a lean row type.'
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProjectRef' }
        total: { type: integer, description: 'Total visible rows matching every filter across every page; the same for every page of one walk and the same total listProjects reports for the same filters.' }
        next_cursor:
          type: string
          nullable: true
          description: 'Opaque cursor for the next page''s `after` parameter; null when this is the last page.'
    PortfolioDetail:
      type: object
      description: 'The full getPortfolio body: metadata, explicit project ids, group rules, a preview of the resolved project set (see GET /config/portfolios/{id}/projects for the rest), the portfolio''s coverage aggregate, its score, and a per-policy score breakdown.'
      required: [id, name, color, projects, rules, resolved_projects, resolved_count, aggregate, score_points, score_letter, breakdown, freshness_summary, policy_ids, excluded_projects]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        color: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        projects:
          type: array
          items: { type: string, format: uuid }
          description: 'Explicit project ids only; resolved_projects also includes rule matches. Not capped.'
        rules:
          type: array
          items: { type: string }
          description: |-
            Each rule is a group path prefix (e.g. `team-a`), not a reference to a group object. A project belongs if its path equals the rule or sits under it: `team-a` matches `team-a/api` and `team-a/sub/web`, but never `team-alpha`. Matching is dynamic, so projects that later appear under the path join automatically.

            - A path with a typo is accepted and simply matches nothing.
            - Rules track the path string, not the group's identity: if a group is renamed on the provider, the rule stops matching until it is updated to the new path.
        excluded_projects:
          type: array
          items: { type: string, format: uuid }
          description: 'Project ids excluded from this portfolio''s rule matches, written by replacePortfolioExcludedProjects. The resolved set is (projects plus rule matches) minus this list; resolved_projects and resolved_count already have it applied. Each id was matched by a rule when it was added and is not an explicit member; an id a rule no longer matches is kept as stored and excludes nothing. Not visibility-filtered, not paginated; always present, `[]` when nothing is excluded.'
        resolved_projects:
          type: array
          items: { $ref: '#/components/schemas/ProjectRef' }
          description: 'First page of the resolved project preview: explicit projects plus every project matching a rule, minus excluded_projects and provider-deleted projects, computed at read time, ordered by (project_path, id), capped at 100. Only lists projects the caller can see, so it may be shorter than resolved_count. See GET /config/portfolios/{id}/projects for the rest, keyset-paginated.'
        resolved_count:
          type: integer
          description: 'Total projects the portfolio resolves to (explicit projects plus rule matches, deduplicated, minus excluded_projects and provider-deleted projects), not filtered by caller visibility; the same value as PortfolioSummary.resolved_project_count.'
        aggregate:
          $ref: '#/components/schemas/Overview'
          description: 'The coverage rollup over this portfolio''s resolved project set, limited to the projects the caller can see, computed like GET /overview. A portfolio with no current projects answers every count 0 and adoption_percent 0, not an error.'
        score_points:
          type: number
          format: float
          description: 'The portfolio''s live score: the average of every current project''s latest score, rounded half-up to one decimal, over the projects the caller can see. 0 when the portfolio is unscored; see score_letter to tell that apart from a real 0. A project whose latest run has no scorable policy (degraded collection, every policy score withheld, or nothing evaluated) is excluded from the average, like a project with no run. In an org with several git instances this value covers the caller''s instance only, so it can differ from the live point of GET /config/portfolios/{id}/score-history.'
        score_letter:
          type: string
          description: 'Grade A-E derived from score_points using the CLI''s published letter bands. Empty string (never null) when the portfolio is unscored: no current project has a scorable run. Since score_points is 0 in that case too, this field is what distinguishes "unscored" from "scored at exactly 0".'
        breakdown:
          type: array
          items: { $ref: '#/components/schemas/PortfolioPolicyScore' }
          description: 'One entry per policy that appears in the latest run of any current project the caller can see (the same project set as score_points), whether or not the policy is assigned to the portfolio. A policy deleted since the run is dropped. Always present, `[]` when no project has a run.'
        freshness_summary:
          $ref: '#/components/schemas/FreshnessSummary'
          description: 'The freshness rollup over this portfolio''s resolved projects, limited to the projects the caller can see; see FreshnessSummary for how its counts relate to aggregate.'
        policy_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'The policies assigned to this portfolio, ordered by policy id: the value replacePortfolioPolicies writes and createPortfolio''s policy_ids seeds. Not visibility-filtered.'
    PortfolioPolicyScore:
      type: object
      description: 'One entry of PortfolioDetail.breakdown: one policy''s score across the portfolio''s current projects that carry it.'
      required: [policy_id, name, color, score_points, score_letter, project_count]
      properties:
        policy_id: { type: string, format: uuid }
        name: { type: string, description: 'The policy''s current name, not the name at run time.' }
        color: { type: string, description: 'The policy''s current color.' }
        score_points:
          type: number
          format: float
          description: 'Average of this policy''s latest points per project, over the projects in breakdown''s scope, rounded half-up to one decimal.'
        score_letter: { type: string, description: 'Grade A-E derived from score_points using the CLI''s published letter bands. Never empty here: a policy only appears in breakdown when at least one project''s run carries it.' }
        project_count: { type: integer, description: 'Number of current projects in breakdown''s scope whose latest run carries this policy: the denominator of score_points.' }
    ControlBreakdownEntry:
      type: object
      description: 'One entry of PolicyControlBreakdown.items: one control type''s finding-status counts, summed across the policy''s latest runs as pushed by the CLI.'
      required: [control_type, pass_count, fail_count, not_evaluable_count, other_count, project_count]
      properties:
        control_type: { type: string }
        pass_count:
          type: integer
          description: 'Number of pass findings for this control type, summed over every contributing project''s latest run, as pushed by the CLI.'
        fail_count: { type: integer, description: 'Number of fail findings for this control type, summed the same way as pass_count.' }
        not_evaluable_count: { type: integer, description: 'Number of not_evaluable findings for this control type, summed the same way as pass_count; a degraded control reported by the CLI.' }
        other_count:
          type: integer
          description: 'Number of findings for this control type whose status is outside pass/fail/not_evaluable, summed the same way as pass_count; an unknown status is counted, never rejected. Runs pushed before this field existed read 0.'
        project_count:
          type: integer
          description: 'Number of distinct projects whose latest run contributed at least one finding for this control type.'
    PolicyControlBreakdown:
      type: object
      description: 'The policyControlBreakdown response: one entry per control type seen across the policy''s latest runs, ordered by control_type ascending. Not paginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ControlBreakdownEntry' }
          description: 'Ordered by control_type ascending. A control type with no contributing findings does not appear (no zero-count entries), nor does a control type the CLI declares for a provider this platform does not serve; a control type unknown to this platform does appear.'
    PortfolioProjectsRequest:
      type: object
      description: 'Body of replacePortfolioProjects: the complete desired explicit-project set among the projects the caller can see. A project the caller cannot see is not shown on read and keeps its existing inclusion through a write, whether or not the body mentions it.'
      required: [project_ids]
      properties:
        project_ids: { type: array, items: { type: string, format: uuid } }
    PortfolioExcludedProjectsRequest:
      type: object
      description: 'Body of replacePortfolioExcludedProjects: the complete desired exclusion set, never a diff. Absent or null excluded_project_ids is a 400; an explicit empty array clears every exclusion. At most 100 entries.'
      required: [excluded_project_ids]
      properties:
        excluded_project_ids:
          type: array
          maxItems: 100
          items: { type: string, format: uuid }
    PortfolioRulesRequest:
      type: object
      description: 'Body of replacePortfolioRules: the complete desired rule set, never a diff.'
      required: [group_paths]
      properties:
        group_paths: { type: array, items: { type: string }, description: 'Group path prefixes; see PortfolioDetail.rules for matching semantics. Replaces the complete rule set, never a diff.' }
    PortfolioProjectsPage:
      type: object
      description: 'The listPortfolioProjects response envelope: one keyset page of the portfolio''s resolved project set, same shape as InventoryList.'
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProjectRef' }
          description: 'Only the projects the caller can see, as in getPortfolio''s resolved_projects preview, so a page may hold fewer than per_page items even when more pages remain.'
        total: { type: integer, description: 'Total rows in the resolved set across every page, not filtered by caller visibility; the same value as PortfolioDetail.resolved_count and PortfolioSummary.resolved_project_count.' }
        next_cursor:
          type: string
          nullable: true
          description: 'Opaque cursor for the next page''s `after` parameter; null when this is the last page.'
    PolicyProjectsPage:
      type: object
      description: 'The listPolicyProjects response envelope: one offset-paginated page of the projects the policy directly covers, with per-policy score and issue numbers per row. Direct coverage is the explicit assignments or, when applies_to is all, every live project of the org within the scope filter, whatever its deployment status; archived and provider-deleted projects are excluded. Deep offset pages may be slow on an applies_to all policy.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PolicyProjectEntry' }
          description: 'Only the projects the caller can see, as in getPolicy''s assignments preview, so a page may hold fewer than per_page items even when more pages remain.'
        total:
          type: integer
          description: 'Total projects the policy directly covers within its scope filter, across every page, not filtered by caller visibility; the same value as PolicyDetail.assignments.assigned_project_count. Differs from PolicySummary.assigned_project_count when a scope filter is set.'
        page: { type: integer }
        per_page: { type: integer }
    PolicyProjectEntry:
      type: object
      description: 'One row of PolicyProjectsPage.items: a project the policy covers, with this policy''s own per-project score and open issue count.'
      required: [id, path, name, name_with_namespace, policy_score_points, policy_score_letter, policy_score_withheld, open_issue_count]
      properties:
        id: { type: string, format: uuid }
        path: { type: string }
        name: { type: string, description: 'The project name as cached from the provider at sync (see InventoryProject.name); empty until the project''s next sync.' }
        name_with_namespace: { type: string, description: 'The project name with its namespace as cached from the provider at sync (see InventoryProject.name_with_namespace); empty until the project''s next sync.' }
        policy_score_points:
          type: integer
          description: 'This policy''s own points on the project''s latest run, not the project''s global score (InventoryProject.score_points, averaged across all its policies). 0 when the project has no result for this policy yet.'
        policy_score_letter:
          type: string
          description: 'Grade A-E stored with this policy result, the band of policy_score_points. Empty string when the project has no result for this policy yet, and when policy_score_withheld is true.'
        policy_score_withheld:
          type: boolean
          description: 'true when no control of this result was evaluated (pass or fail), so there is no score: render an unscored state, not a zero. false with a 0 and an empty letter means the project has no result for this policy at all.'
        open_issue_count:
          type: integer
          description: 'Number of this project''s open issues (status Detected or In progress) that this policy requires; not the project''s total open issue count.'
    ScoreHistory:
      type: object
      description: 'The response envelope of policyScoreHistory, portfolioScoreHistory and projectScoreHistory: a bounded daily series in the org calendar, not a paginated list. from and to echo the range actually served after defaulting.'
      required: [items, from, to, timezone]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ScoreHistoryItem' }
          description: 'Ordered by day ascending. A day with no data is absent (a gap), never a zero entry; a day whose runs all had no scorable policy counts as no data. At most one entry falls on today (a live point, present only when the range includes today and the live compute succeeded); every other entry is a stored value for an earlier day.'
        from: { type: string, format: date, description: 'The lower bound actually served, a calendar date in the org calendar.' }
        to: { type: string, format: date, description: 'The upper bound actually served, a calendar date in the org calendar.' }
        timezone: { type: string, description: 'The IANA time zone the days are calendar dates of: the org''s `timezone` setting, `UTC` by default, the same for every viewer of the org. Render `day` as a label in this calendar, not through the browser clock.' }
    ScoreHistoryItem:
      type: object
      description: 'One day''s score point in a ScoreHistory series.'
      required: [day, score_points, score_letter]
      properties:
        day: { type: string, format: date, description: 'Calendar date in the time zone `timezone` names, never UTC and never the viewer''s own zone.' }
        score_points:
          type: number
          description: 'The day''s score in points. An integer for a project; an average rounded half-up to one decimal for a policy or portfolio.'
        score_letter: { type: string, description: 'Grade A-E derived from score_points using the CLI''s published letter bands on every read.' }
    ScoreHistoryBatch:
      type: object
      description: 'The response envelope of policyScoreHistoryBatch and portfolioScoreHistoryBatch: several entities'' daily series in one call, each with the same range semantics as the single-entity endpoints (90-day default, 400-day cap, from <= to, gaps stay gaps, ascending days, one live point for today per entity). from and to echo the one range served for the whole batch.'
      required: [items, from, to, timezone]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ScoreHistoryBatchItem' }
          description: 'One entry per requested id, in the order of the `ids` query parameter. An unknown id yields an entry with an empty items list, never an error.'
        from: { type: string, format: date, description: 'The lower bound actually served, a calendar date in the org calendar, shared by every id in items.' }
        to: { type: string, format: date, description: 'The upper bound actually served, a calendar date in the org calendar, shared by every id in items.' }
        timezone: { type: string, description: 'The IANA time zone the days are calendar dates of: the org''s `timezone` setting, `UTC` by default, the same for every viewer of the org. Render `day` as a label in this calendar, not through the browser clock.' }
    ScoreHistoryBatchItem:
      type: object
      description: 'One entity''s daily score series inside a ScoreHistoryBatch.'
      required: [id, items]
      properties:
        id: { type: string, format: uuid, description: 'The policy or portfolio id this series belongs to, echoing one entry of the request''s `ids` parameter.' }
        items:
          type: array
          items: { $ref: '#/components/schemas/ScoreHistoryItem' }
          description: 'Ordered by day ascending, with the same gap rule as ScoreHistory.items (a day with no data is absent). Empty for an unknown id or one with no data, never an error.'

    ProjectBadgeSettings:
      type: object
      description: 'Request and response body of updateProjectBadge: the project''s own badge toggle. See PUT /projects/{id}/badge for the full eligibility gate.'
      required: [enabled]
      properties:
        enabled:
          type: boolean
          description: 'Whether this project''s score badge is enabled. The badge is served only when this is true, the org toggle OrgSettings.badge_enabled is on, and the project is public on the provider. Off by default.'
    ProjectOfficialComponentSettings:
      type: object
      description: 'Request and response body of updateProjectOfficialComponent: the org-curated official component repo flag, independent of is_component_repo.'
      required: [official]
      properties:
        official:
          type: boolean
          description: 'Whether this project is curated as an official component repo. Off by default. Stands alone: it is never reflected in is_component_repo.'
    IssueStatus:
      type: string
      description: 'The issue status vocabulary: exactly these four values.'
      enum: [Detected, 'In progress', Fixed, Dismissed]
    IssueSummary:
      type: object
      description: 'One row of the issues list (see listIssues). A finding is one run''s per-control observation as the CLI pushed it; an issue is the durable, deduplicated, status-carrying entity the platform builds from findings across runs.'
      required: [id, project_path, project, control_type, severity, base_severity, status, owner, requiring_policies, requiring_policy_refs, analysis_in_progress, first_detected_at, updated_at, fixable, display]
      properties:
        id: { type: string, format: uuid }
        project_path: { type: string }
        project:
          allOf: [{ $ref: '#/components/schemas/InventoryProject' }]
          description: 'The project this issue belongs to, in the same InventoryProject shape GET /projects serves, embedded on every issue row and on the detail. project_path is an alias of project.path.'
        control_type: { type: string }
        code:
          type: string
          description: 'The finding''s ISSUE-nnn code, the join key for GET /issue-types (one control owns several issue types). Present on every issue; never an empty string.'
        severity: { type: string, description: 'The severity the CLI pushed for this run (critical, high, medium, low): with the contextual score, the tier of the strongest attack path the finding belongs to; otherwise the base severity. Empty string when the finding carries none.' }
        base_severity: { type: string, description: 'The issue type''s base severity from the CLI''s registry (critical, high, medium, low). `severity` is the severity shown for this run: with the contextual score it is the tier of the strongest attack path the finding belongs to, so the two can differ. Equals `severity` for a finding of a CLI older than v0.6.0; empty when the finding carries neither.' }
        status: { $ref: '#/components/schemas/IssueStatus' }
        owner: { type: string, description: 'The owner''s platform user id; empty string when unassigned.' }
        owner_avatar_url:
          type: string
          nullable: true
          description: 'The owner''s provider avatar URL as of their last login. The key is omitted (never null, never empty) when the issue is unassigned, the owner is not a user of this org, or the user has no stored avatar.'
        requiring_policies:
          type: array
          items: { type: string }
          description: 'The current display names of the policies requiring this issue; an entry that cannot be resolved to a policy appears as stored.'
        requiring_policy_refs:
          type: array
          items: { $ref: '#/components/schemas/RequiringPolicyRef' }
          description: 'The same policies as requiring_policies, each with its id when the entry resolves to a policy of this org; otherwise id is absent and name carries the stored entry.'
        analysis_in_progress: { type: boolean, description: 'Whether the issue''s project has a run in flight: true when the embedded project''s freshness is running.' }
        first_detected_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        fixable:
          type: boolean
          description: 'Whether the issue is currently eligible for triggerFix: a fixer exists for its control type and code, and its status is Detected or In progress.'
        latest_fix_status:
          allOf: [{ $ref: '#/components/schemas/FixStatus' }]
          description: 'The status of the most recent fix triggered for this issue. The key is omitted when no fix was ever triggered. Unlike fixable, this is a backward-looking signal: use it to show that a fix is already pending, opened or done before triggering another.'
        display:
          allOf: [{ $ref: '#/components/schemas/IssueDisplay' }]
          description: 'A bounded projection of the finding''s data for display. Always present; an empty object when the finding carries none of its fields.'
        recommendation:
          allOf: [{ $ref: '#/components/schemas/IssueRecommendation' }]
          description: 'Present only when the finding carries both its current and its target version (today ISSUE-403, includesMustBeUpToDate); absent otherwise. source is always cli.'
    IssueRecommendation:
      type: object
      description: 'A version recommendation carried by the finding: its current and its target version. source is always cli.'
      required: [kind, current, recommended, source]
      properties:
        kind:
          type: string
          enum: [version]
        current: { type: string }
        recommended: { type: string }
        source:
          type: string
          enum: [cli]
    RequiringPolicyRef:
      type: object
      description: 'One entry of IssueSummary.requiring_policy_refs: a requiring policy''s name paired with its id when resolvable.'
      required: [name]
      properties:
        id:
          type: string
          format: uuid
          description: 'The policy''s platform id. Absent when the stored entry does not resolve to a policy of this org.'
        name: { type: string, description: 'The policy''s current display name when id is present; otherwise the stored entry as-is, identical to the matching requiring_policies entry.' }
    IssueDisplay:
      type: object
      description: 'The display projection of a finding''s data. Every member is optional: absent means the CLI did not push that field, never a placeholder.'
      properties:
        message:
          type: string
          description: 'The finding''s message, control characters stripped, capped at 500 characters with a trailing "..." on overflow. Absent when the finding carries none.'
        file: { type: string, description: 'The finding''s file path, same stripping and cap as message. Absent when the finding carries none (many GitLab-family controls carry no file).' }
        line:
          type: integer
          minimum: 0
          description: 'The finding''s line number. Present only when the pushed value is a whole, non-negative number; otherwise absent, never 0.'
        url:
          type: string
          description: 'The finding''s reference URL, same stripping and cap as message. Served only when it is an absolute http or https URL; any other value is omitted. Absent when the finding carries none.'
        has_secret:
          type: boolean
          description: 'Present and true only when the CLI declared the finding''s variable a secret (provenance settings_secret); no secret value is ever stored or served. Absent, never false, otherwise.'
        missing_groups:
          type: array
          description: 'The finding''s `data.missingGroups` as pushed: one inner list per required-group alternative, in configured order. Carried by ISSUE-405 (no required template), ISSUE-408 (no required component) and ISSUE-417 (required action or workflow missing). Absent when the finding carries no such key or one of another shape; an empty inner list means that alternative names no missing member. The same value is inside IssueDetail.data.'
          items:
            type: array
            items: { type: string }
    IssueList:
      type: object
      description: 'The listIssues response envelope: one page of IssueSummary rows plus pagination metadata.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/IssueSummary' }
        total: { type: integer, description: 'Total rows visible to the caller and matching every filter, across every page.' }
        page: { type: integer }
        per_page: { type: integer }
    IssuesSummary:
      type: object
      description: 'The getIssuesSummary response: header counts over the issues visible to the caller and matching the filters. `total` equals listIssues'' `total` for the same query string; `by_status` ignores the `status` filter and applies every other filter.'
      required: [total, by_status, by_severity]
      properties:
        total: { type: integer, description: 'Issues visible to the caller and matching every filter, including the default status rule; identical to IssueList.total for the same query string.' }
        by_status:
          type: object
          description: 'Count per issue status, with the `status` filter not applied and every other filter applied. All four keys are always present, 0 included, spelled as the IssueStatus enum spells them ("In progress" carries a space).'
          required: [Detected, 'In progress', Fixed, Dismissed]
          properties:
            Detected: { type: integer }
            'In progress': { type: integer }
            Fixed: { type: integer }
            Dismissed: { type: integer }
        by_severity:
          type: object
          description: 'Count per severity over the same issues `total` counts (the `status` filter included). All four keys are always present, 0 included. An issue with no severity is counted in `total` and `by_status` but in no bucket here, so the sum may be smaller than `total`.'
          required: [critical, high, medium, low]
          properties:
            critical: { type: integer }
            high: { type: integer }
            medium: { type: integer }
            low: { type: integer }
    IssuesTrendPoint:
      type: object
      description: 'One day''s open-issue count in an IssuesTrend series.'
      required: [day, open_count]
      properties:
        day: { type: string, format: date, description: 'Calendar date in the time zone `timezone` names, never UTC and never the viewer''s own zone.' }
        open_count:
          type: integer
          description: 'Number of matching issues whose status was Detected or In progress at the end of this day in the org calendar. 0 is a real count, never an omitted gap.'
    IssuesTrend:
      type: object
      description: 'The issuesTrend response envelope: a bounded series of daily open-issue counts, one point per calendar day of the org calendar, same shape as ScoreHistory but with no gaps.'
      required: [items, from, to, timezone]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/IssuesTrendPoint' }
          description: 'Ordered by day ascending. One entry per day in the served range, never a gap (unlike ScoreHistory.items).'
        from: { type: string, format: date, description: 'The lower bound actually served, a calendar date in the org calendar.' }
        to: { type: string, format: date, description: 'The upper bound actually served, a calendar date in the org calendar.' }
        timezone: { type: string, description: 'The IANA time zone the days are calendar dates of: the org''s `timezone` setting, `UTC` by default, the same for every viewer of the org. Render `day` as a label in this calendar, not through the browser clock.' }
    IssueDetail:
      description: 'The full getIssueDetail body: everything IssueSummary carries plus the control configuration, the finding''s display payload, and the full status-move history.'
      allOf:
        - $ref: '#/components/schemas/IssueSummary'
        - type: object
          required: [control_version, configuration, data, history, run_count]
          properties:
            control_version: { type: string }
            configuration:
              type: object
              description: 'The control''s resolved configuration in effect at the last detection, refreshed on every match. Informational only: it is not part of the issue identity, so editing a control''s configuration keeps the issue and its triage.'
            data:
              type: object
              description: 'The finding''s full display payload, exactly as pushed.'
            history:
              type: array
              items: { $ref: '#/components/schemas/IssueHistoryEntry' }
              description: 'One chronological history of the issue, newest first: every status move and comment merged with every fix attempt, each entry typed by `kind` and carrying its `actor` (the acting user, or Plumber when the platform acted). Entries are sorted on `at` alone; a status move and a fix attempt at the same instant have no defined order. A fix entry also carries `mr_iid` and `mr_url` when the fix opened a merge request.'
            run_count:
              type: integer
              description: 'How many runs have ever been associated with this issue.'
    IssueHistoryKind:
      type: string
      description: 'The kind of an IssueDetail.history entry: status (a status move or a comment-only entry) or fix (one fix attempt). Treat an unrecognized kind as an entry this client cannot render, not as an error.'
      enum: [status, fix]
    IssueHistoryActorKind:
      type: string
      description: 'Who acted on an IssueDetail.history entry: user (a platform user, named by id) or plumber (the platform itself).'
      enum: [user, plumber]
    IssueHistoryActor:
      type: object
      description: 'The actor of an IssueDetail.history entry, always present. kind is plumber for every automatic action: a detection, a closure, a reopen on redetection, a policy-removal closure, a fix triggered by the platform, and a fix whose triggering user was since deleted.'
      required: [kind]
      properties:
        kind: { $ref: '#/components/schemas/IssueHistoryActorKind' }
        id:
          type: string
          description: 'Present when kind is user: the acting platform user id as stored, which may be a legacy non-uuid value. Absent when kind is plumber.'
        name:
          type: string
          description: 'The acting user''s current display name. Absent when kind is plumber, and when the id does not resolve to a user of the caller''s org (a legacy actor, or a user since deleted); the id is still served.'
    IssueHistoryEntry:
      type: object
      description: 'One entry of the merged issue history. `kind` says whether it is a status entry or a fix entry, and which fields are present; the other kind''s fields are absent. A fix contributes one entry at its creation (fix_status being its current status) plus, when that status is terminal (done, failed, merged) and its last update differs from its creation, a second entry at that update with the same fix_id; intermediate transitions are not individually recorded. A fix entry carries `mr_iid` and `mr_url` when the fix opened a merge request, on both entries.'
      required: [kind, at, actor]
      properties:
        kind: { $ref: '#/components/schemas/IssueHistoryKind' }
        at:
          type: string
          format: date-time
          description: 'When this entry happened: the status move''s time for a status entry, the fix''s creation or last update for a fix entry. The single newest-first sort key of the list.'
        actor: { $ref: '#/components/schemas/IssueHistoryActor' }
        from_status:
          allOf: [{ $ref: '#/components/schemas/IssueStatus' }]
          nullable: true
          description: 'Present when kind is status, except on the issue''s very first entry (no prior status). Absent for a fix entry.'
        to_status:
          allOf: [{ $ref: '#/components/schemas/IssueStatus' }]
          description: 'Present when kind is status: the status after the move (equal to from_status for a comment-only entry). Absent for a fix entry.'
        reason: { type: string, description: 'Present when kind is status: one of detected, no_longer_detected, policy_removed, reopened_by_detection, manual, redetected_while_dismissed. Absent for a fix entry.' }
        comment: { type: string, description: 'The comment a manual move or a comment-only entry carried. Absent when the entry carries none (an automatic move never does), never an empty string.' }
        fix_id: { type: string, format: uuid, description: 'Present when kind is fix: the fix id, the same id FixHistoryEntry.id carries. Both entries of a terminal fix carry this one id.' }
        fix_status:
          allOf: [{ $ref: '#/components/schemas/FixStatus' }]
          description: 'Present when kind is fix: the fix''s current status, on both entries of a terminal fix; an earlier transition is never reconstructed.'
        fix_error: { type: string, description: 'Present when kind is fix and the fix failed: the same diagnostic string FixHistoryEntry.error serves. Absent otherwise.' }
        mr_iid: { type: integer, nullable: true, description: 'Present when kind is fix and the fix opened a merge request: that MR''s project-scoped iid, the same value FixHistoryEntry.mr_iid serves. Absent when the fix opened none, and for a status entry.' }
        mr_url:
          type: string
          description: 'Present when kind is fix once the fix reached mr_opened or merged: the merge request''s web URL. Omitted, never an empty string, otherwise, and absent for a status entry.'
    IssueStatusRequest:
      type: object
      description: 'Body of setIssuesStatus: a batch manual status move. Allowed moves: Detected <-> In progress, Detected or In progress -> Dismissed, Dismissed -> Detected. Fixed cannot be set manually; it is set by the next push.'
      required: [issue_ids, to_status]
      properties:
        issue_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 500 entries; 400 otherwise, before any issue is touched.'
        to_status: { $ref: '#/components/schemas/IssueStatus' }
        comment:
          type: string
          description: 'Optional; recorded in the history of every issue the call moves. Bounded in length (an over-limit comment fails the whole request with 400). Unicode control, format and line-separator characters other than LF, CR and TAB are rejected with 400 naming the code point.'
    IssueOwnerRequest:
      type: object
      description: 'Body of setIssuesOwner: a batch manual owner assignment.'
      required: [issue_ids, owner]
      properties:
        issue_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 500 entries; 400 otherwise, before any issue is touched.'
        owner:
          type: string
          description: 'The platform user id of an existing user of the caller''s org, or the empty string to unassign. Anything else (malformed, unknown, or a user of another org) fails the whole request with 422.'
    IssueCommentRequest:
      type: object
      description: 'Body of addIssueComment: a comment-only history entry, no status change.'
      required: [comment]
      properties:
        comment:
          type: string
          description: 'Required, non-empty (whitespace-only counts as empty) and bounded in length; 400 otherwise. Unicode control, format and line-separator characters other than LF, CR and TAB are rejected with 400 naming the code point.'
    IssueBatchFailureReason:
      type: string
      description: 'The vocabulary of per-issue batch failure reasons.'
      enum: [not_found, forbidden_transition, already_in_status]
    IssueBatchFailure:
      type: object
      description: 'One failed entry of a setIssuesStatus or setIssuesOwner result.'
      required: [id, reason]
      properties:
        id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/IssueBatchFailureReason' }
    IssueBatchResult:
      type: object
      description: 'The 200 body of setIssuesStatus and setIssuesOwner: per-issue results; one bad id never fails the whole request.'
      required: [updated, failed]
      properties:
        updated:
          type: array
          items: { type: string, format: uuid }
          description: 'Issue ids the call actually applied a change to.'
        failed:
          type: array
          items: { $ref: '#/components/schemas/IssueBatchFailure' }

    FixStatus:
      type: string
      description: 'The fix status vocabulary: pending, done and failed for a fix applied directly; mr_opened and merged for a fix delivered as a merge request.'
      enum: [pending, done, failed, mr_opened, merged]
    FixRejectionReason:
      type: string
      description: 'The vocabulary of triggerFix per-item rejection reasons. duplicate_pending: the issue already carries a fix at pending or mr_opened. choice_required: the issue''s control configures several alternative required-include groups and the item''s inputs name none; re-submit with the chosen group in inputs.group (a 0-based decimal index). A missing token is not a rejection reason: it surfaces as a failed fix (FixHistoryEntry.error).'
      enum: [not_found, not_fixable, duplicate_pending, choice_required]
    FixItemRequest:
      type: object
      description: 'One item of a triggerFix batch.'
      required: [issue_id]
      properties:
        issue_id: { type: string, format: uuid }
        inputs:
          type: object
          description: |-
            Optional per-fix input values, stored as given. Every string value is at most 1 KB and the whole object at most 4 KB; a bound breach fails the request with 400 "inputs exceed the allowed bounds". Two keys are interpreted:

            - `group` (string): the chosen alternative when the requiring policy configures several, as a 0-based index in decimal string form; any other JSON type is a 400.
            - `components` (object): the input values a component-adding fix writes into the include it adds, keyed by the component `path` getFixInputs serves (at most 512 bytes), each value an object of that component's inputs. An input name must match `^[A-Za-z0-9_][A-Za-z0-9_.-]*$` and be at most 128 bytes. An input value is a scalar (string, boolean, number), a list of at most 64 scalars, or null (no value: never written, never counted as supplied for a required input). Every string must be printable text: control characters, line breaks of any kind and invisible formatting characters are refused. A path naming a component the fix does not add is ignored, the Plumber `platform` input is set by the platform and a supplied one is dropped, and any other shape is a 400 "inputs.components must be an object of objects". A value is written according to the component's declared input type: a `string` input (also the default when the type is absent) is always written as text, so `yes`, `null`, `1e3`, `0x10` or an empty string comes back as exactly that text; any other declared type is written as YAML reads it, so submit a JSON boolean for a `boolean` input and a JSON number for a `number` one.

            Every other key is stored and never interpreted.
    FixTriggerRequest:
      type: object
      description: 'Body of triggerFix: a batch of issues to trigger a fix for.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/FixItemRequest' }
          description: 'Non-empty, at most 100 entries; 400 otherwise, before any item is processed.'
    FixAccepted:
      type: object
      description: 'One accepted item of a triggerFix result: the pending fix created for it.'
      required: [issue_id, fix_id]
      properties:
        issue_id: { type: string, format: uuid }
        fix_id: { type: string, format: uuid }
    FixRejected:
      type: object
      description: 'One rejected item of a triggerFix result.'
      required: [issue_id, reason]
      properties:
        issue_id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/FixRejectionReason' }
        detail:
          type: string
          description: 'Optional: one sentence saying why this item was rejected when the reason alone does not say it. Present today only for a `not_fixable` item whose ISSUE-505 reasons all need branch protection fields the git instance discards. Absent otherwise, never an empty string. Safe to show as-is; do not parse it, `reason` is the machine-readable answer.'
    FixTriggerResult:
      type: object
      description: 'The 200 body of triggerFix: per-item results; one rejected item never fails the whole request.'
      required: [accepted, rejected]
      properties:
        accepted:
          type: array
          items: { $ref: '#/components/schemas/FixAccepted' }
        rejected:
          type: array
          items: { $ref: '#/components/schemas/FixRejected' }
    FixHistoryEntry:
      type: object
      description: 'One row of listFixHistory: a fix plus its project and issue context. A single issue''s own fix attempts are the `fix` entries of IssueDetail.history.'
      required: [id, issue_id, project_id, project_path, project_name, status, error, fixer_version, created_at, updated_at, mr_url]
      properties:
        id: { type: string, format: uuid }
        issue_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        project_path: { type: string }
        project_name:
          type: string
          description: 'The project name as cached from the provider at sync (see InventoryProject.name); empty until the project''s next sync.'
        user_id: { type: string, format: uuid, nullable: true, description: 'The acting user''s platform id; null only if that user was later deleted.' }
        status: { $ref: '#/components/schemas/FixStatus' }
        error: { type: string, description: 'The diagnostic string when status is failed; empty string otherwise.' }
        fixer_version: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        mr_iid: { type: integer, nullable: true, description: 'The opened merge request''s project-scoped iid; null unless status is mr_opened or merged.' }
        mr_url: { type: string, description: 'The opened merge request''s web URL; empty string unless status is mr_opened or merged.' }
    FixHistoryList:
      type: object
      description: 'The listFixHistory response envelope: one page of FixHistoryEntry rows plus pagination metadata, same shape as IssueList.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/FixHistoryEntry' }
        total: { type: integer, description: 'Total rows visible to the caller and matching every filter, across every page.' }
        page: { type: integer }
        per_page: { type: integer }
    FixMergeFailureReason:
      type: string
      description: 'The vocabulary of mergeFixes per-item failure reasons.'
      enum: [not_found, not_mergeable, merge_failed]
    FixMergeRequest:
      type: object
      description: 'Body of mergeFixes: a batch of fix ids in status mr_opened to merge.'
      required: [fix_ids]
      properties:
        fix_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; 400 otherwise, before any item is processed.'
    FixMergeFailure:
      type: object
      description: 'One failed item of a mergeFixes result.'
      required: [id, reason]
      properties:
        id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/FixMergeFailureReason' }
    FixMergeResult:
      type: object
      description: 'The 200 body of mergeFixes: per-item results; one failed item never fails the whole request.'
      required: [merged, failed]
      properties:
        merged:
          type: array
          items: { type: string, format: uuid }
          description: 'Fix ids whose merge request was successfully merged.'
        failed:
          type: array
          items: { $ref: '#/components/schemas/FixMergeFailure' }

    FixComponentInput:
      type: object
      description: 'One input a component''s `spec.inputs` block declares, read from the component template at the ref the fix would pin.'
      required: [name, type, required]
      properties:
        name: { type: string, description: 'The input''s name, the key `inputs.components[<component path>]` takes on triggerFix. At most 128 bytes.' }
        type:
          type: string
          description: 'The spec''s `type` (GitLab declares `string`, `number`, `boolean`, `array`), at most 32 bytes. `string` also covers a spec that declares no type or one this platform cannot serve. Not an enum: a self-hosted instance may declare a type this platform has never seen.'
        required: { type: boolean, description: 'True when the spec declares no `default` for this input. Gate the fix action on this field.' }
        default: { description: 'The default the spec declares, in the spec''s own JSON type (string, number, boolean or list); present only when the spec declares one. Gate on `required`, not on this field''s presence: an empty default (`default:`) is not required and carries nothing here, and a default over 1 KB in JSON form is omitted.' }
        description: { type: string, description: 'The spec''s description for this input, truncated at 1 KB. Omitted when the spec declares none.' }
        options:
          type: array
          items: { description: 'One allowed value, in the spec''s own JSON type.' }
          description: 'The list of values the spec allows; when the spec declares more than 32, the first 32 are served. Omitted only when the spec declares no options. A value over 256 bytes in JSON form makes the whole component `readable: false`.'
        regex: { type: string, description: 'The pattern the spec constrains this input to, at most 1 KB. Omitted when the spec declares none. Applied by GitLab at pipeline time, not by the platform.' }
    FixComponentDescription:
      type: object
      description: 'One component a fix for this issue would add, and what its spec declares.'
      required: [path, readable, inputs]
      properties:
        path: { type: string, description: 'The component path as the requiring policy configures it (`<repository path>/<component name>`), the key `inputs.components` takes on triggerFix.' }
        ref:
          type: string
          description: 'The ref the fix would pin this component at: the component repository''s newest release tag. Omitted when it could not be resolved or the repository has nothing pinnable.'
        readable: { type: boolean, description: 'True when the component''s spec was read and parsed at `ref`. When false, `inputs` is empty and `error` says why; never a guessed or partial schema.' }
        error:
          type: string
          description: 'One sentence saying why the component could not be described, present only when `readable` is false: the org token cannot read it, the template is absent or unparseable, the read failed, or the spec is too large to serve (more than 64 inputs, an input name over 128 bytes, or an option over 256 bytes).'
        inputs:
          type: array
          items: { $ref: '#/components/schemas/FixComponentInput' }
          description: 'The declared inputs, ordered by name. Always present; `[]` when the spec declares none or the component could not be read.'
    FixInputsResponse:
      type: object
      description: 'The 200 body of getFixInputs: what a fix for this issue could add, and what each of those components asks for.'
      required: [groups, components]
      properties:
        groups:
          type: array
          items: { type: array, items: { type: string } }
          description: 'The requiring policy''s alternatives, each a list of component paths, in the same order and 0-based indexing triggerFix''s `inputs.group` uses. `[]` when the policy configures no required entries.'
        components:
          type: array
          items: { $ref: '#/components/schemas/FixComponentDescription' }
          description: 'One entry per distinct component path across `groups`, in first-appearance order, at most 20 (beyond that the request answers 422).'

    OnboardRejectionReason:
      type: string
      description: |-
        The vocabulary of onboardProjects per-item rejection reasons.

        - `ci_config_external`: the project's CI configuration path names an external location other than this instance's central file, or any external location on an instance using the CI file method.
        - `external_ci_config_not_ready`: the instance integrates through an external CI configuration and its central file is not ready: never checked, unreadable, or missing the component.
      enum: [not_found, not_onboardable, no_component, already_pending, provider_unsupported, policy_not_found, policy_import_failed, ci_disabled, ci_config_external, external_ci_config_not_ready]
    OnboardWarningReason:
      type: string
      description: |-
        Non-blocking warnings computed for an accepted onboarding item; further values may be added, so treat an unknown value as informational. OnboardAccepted.warnings is computed by the onboard request itself and carries `no_shared_runners` and `plumber_yaml_controls_skipped` only; InventoryPendingOnboarding.warnings is recorded later by the onboarding and carries `stage_input_undeclared` and `no_plumber_job`. `in_repo_ci_ignored` and `first_run_not_started` are recorded on the onboarding attempt by the external CI configuration method; no field of this API serves them yet.

        - `plumber_yaml_controls_skipped`: the `.plumber.yaml` import left at least one control out because its configuration does not match the schema the platform serves; the policy was still created or matched from the rest of the file, and `dropped_controls` on the same item names each skipped control and its violations.
        - `stage_input_undeclared`: no `stage` input was written into the component include, either because the component version declares no such input or because the project's own CI configuration could not be read (too large, or not valid YAML). The merge request was still opened and says which case it is; the component runs at its own default stage, before the project's own stages.
        - `no_plumber_job`: the provider's merged CI for the file this onboarding wrote carries no job named exactly `plumber`, so the platform's re-check and pipeline signal cannot find it and the project analyses only when its pipeline runs on its own. The merge request was still opened.
        - `in_repo_ci_ignored`: the project has its own CI file; while its CI configuration path points at the central file, GitLab ignores that file.
        - `first_run_not_started`: the setting was written but the first pipeline could not be started; the next push, or a later re-check once a run exists, starts it.
      enum: [no_shared_runners, plumber_yaml_controls_skipped, stage_input_undeclared, no_plumber_job, in_repo_ci_ignored, first_run_not_started]
    OnboardRequest:
      type: object
      description: 'Body of onboardProjects: a batch of projects to start onboarding, plus the complete directly-assigned policy set to apply to each of them.'
      required: [project_ids, policy_ids]
      properties:
        project_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; 400 otherwise, before any item is processed.'
        policy_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'The complete directly-assigned policy set for every project in the batch, never a diff. The key is required (absent is a 400 naming the field), but an empty array is legal: the project is then governed by the policies it inherits through its portfolios or, failing that, by the report-only "[Plumber default]" policy. At most 100 entries; 400 otherwise, before any item is processed.'
        use_project_policy_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'The subset of project_ids for which the .plumber.yaml policy offer, if any, is re-derived and applied: on a match the existing policy joins the item''s assigned set, otherwise a new "Custom - {project_path}" policy is created. Entries outside project_ids have no effect; empty or absent means no item uses the offer. At most 100 entries; 400 otherwise, before any item is processed.'
    OnboardAccepted:
      type: object
      description: 'One accepted item of an onboardProjects result: the onboarding attempt started for it.'
      required: [project_id, onboarding_id]
      properties:
        project_id: { type: string, format: uuid }
        onboarding_id: { type: string, format: uuid }
        warnings:
          type: array
          items: { $ref: '#/components/schemas/OnboardWarningReason' }
          description: 'Non-blocking warnings for this accepted item. Absent or empty when none fired.'
        dropped_controls:
          type: array
          description: 'The controls the `.plumber.yaml` import skipped, with their violations. Absent or empty when none was dropped; when non-empty, `warnings` also carries `plumber_yaml_controls_skipped`. The policy was still created or matched from the remaining controls.'
          items: { $ref: '#/components/schemas/ControlImportDrop' }
    OnboardRejected:
      type: object
      description: 'One rejected item of an onboardProjects result.'
      required: [project_id, reason]
      properties:
        project_id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/OnboardRejectionReason' }
        detail:
          type: string
          description: 'Present when reason is policy_import_failed: a plain-language explanation. Absent for every other reason.'
    OnboardResult:
      type: object
      description: 'The 200 body of onboardProjects: per-item results; one rejected item never fails the whole request.'
      required: [accepted, rejected]
      properties:
        accepted:
          type: array
          items: { $ref: '#/components/schemas/OnboardAccepted' }
        rejected:
          type: array
          items: { $ref: '#/components/schemas/OnboardRejected' }
    OnboardMergeFailureReason:
      type: string
      description: 'The vocabulary of mergeOnboardings per-item failure reasons. merge_not_ready: the merge request is still open and the provider had not finished computing its mergeability, or reports it mergeable; retry later. not_approved, discussions_not_resolved, ci_must_pass, need_rebase, conflict: the merge request is still open and blocked by exactly that provider condition. merge_failed: every other refused merge (an unmapped provider status, insufficient rights, or the caller''s write token being unavailable).'
      enum: [not_found, not_mergeable, merge_failed, merge_not_ready, not_approved, discussions_not_resolved, ci_must_pass, need_rebase, conflict]
    OnboardMergeRequest:
      type: object
      description: 'Body of mergeOnboardings: a batch of onboarding ids, each with an open merge request, to merge.'
      required: [onboarding_ids]
      properties:
        onboarding_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; 400 otherwise, before any item is processed.'
    OnboardMergeFailure:
      type: object
      description: 'One failed item of a mergeOnboardings result.'
      required: [id, reason]
      properties:
        id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/OnboardMergeFailureReason' }
    OnboardMergeResult:
      type: object
      description: 'The mergeOnboardings response: per-item results; some items may succeed while others fail.'
      required: [merged, failed]
      properties:
        merged:
          type: array
          items: { type: string, format: uuid }
          description: 'Onboarding ids whose merge request was successfully merged.'
        failed:
          type: array
          items: { $ref: '#/components/schemas/OnboardMergeFailure' }
    UnboardRejectionReason:
      type: string
      description: 'The closed vocabulary of unboardProjects per-item rejection reasons.'
      enum: [not_found, not_unboardable, already_pending, in_flight, provider_unsupported, ci_config_path_changed]
    UnboardRequest:
      type: object
      description: 'Body of unboardProjects: a batch of onboarded projects to start removing Plumber from.'
      required: [project_ids]
      properties:
        project_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; otherwise the whole request is rejected with 400 before any item is processed.'
    UnboardAccepted:
      type: object
      description: 'One accepted item of an unboardProjects result: the unboarding attempt started for it.'
      required: [project_id, unboarding_id]
      properties:
        project_id: { type: string, format: uuid }
        unboarding_id: { type: string, format: uuid }
    UnboardRejected:
      type: object
      description: 'One rejected item of an unboardProjects result.'
      required: [project_id, reason]
      properties:
        project_id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/UnboardRejectionReason' }
    UnboardResult:
      type: object
      description: 'The unboardProjects response: per-item results; some items may be accepted while others are rejected.'
      required: [accepted, rejected]
      properties:
        accepted:
          type: array
          items: { $ref: '#/components/schemas/UnboardAccepted' }
        rejected:
          type: array
          items: { $ref: '#/components/schemas/UnboardRejected' }
    UnboardMergeFailureReason:
      type: string
      description: 'Per-item failure reasons for mergeUnboardings. merge_not_ready: the merge request is still open but the provider has not finished computing whether it is mergeable; retry shortly. merge_failed: the provider refused the merge (conflicts, unresolved discussions, insufficient rights).'
      enum: [not_found, not_mergeable, merge_failed, merge_not_ready]
    UnboardMergeRequest:
      type: object
      description: 'Body of mergeUnboardings: a batch of unboarding ids, each with an open removal merge request, to merge.'
      required: [unboarding_ids]
      properties:
        unboarding_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; otherwise the whole request is rejected with 400 before any item is processed.'
    UnboardMergeFailure:
      type: object
      description: 'One failed item of a mergeUnboardings result.'
      required: [id, reason]
      properties:
        id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/UnboardMergeFailureReason' }
    UnboardMergeResult:
      type: object
      description: 'The mergeUnboardings response: per-item results; some items may succeed while others fail.'
      required: [merged, failed]
      properties:
        merged:
          type: array
          items: { type: string, format: uuid }
          description: 'Unboarding ids whose removal merge request was successfully merged.'
        failed:
          type: array
          items: { $ref: '#/components/schemas/UnboardMergeFailure' }
    UnboardCancelFailureReason:
      type: string
      description: 'The closed vocabulary of cancelUnboardings per-item failure reasons.'
      enum: [not_found, not_cancellable, cancel_failed]
    UnboardCancelRequest:
      type: object
      description: 'Body of cancelUnboardings: a batch of unboarding ids, still pending or with an open removal merge request, to cancel.'
      required: [unboarding_ids]
      properties:
        unboarding_ids:
          type: array
          items: { type: string, format: uuid }
          description: 'Non-empty, at most 100 entries; otherwise the whole request is rejected with 400 before any item is processed.'
    UnboardCancelFailure:
      type: object
      description: 'One failed item of a cancelUnboardings result.'
      required: [id, reason]
      properties:
        id: { type: string, format: uuid }
        reason: { $ref: '#/components/schemas/UnboardCancelFailureReason' }
    UnboardCancelResult:
      type: object
      description: 'The cancelUnboardings response: per-item results; some items may succeed while others fail.'
      required: [cancelled, failed]
      properties:
        cancelled:
          type: array
          items: { type: string, format: uuid }
          description: 'Unboarding ids successfully cancelled - restored to their previous deployment_status.'
        failed:
          type: array
          items: { $ref: '#/components/schemas/UnboardCancelFailure' }
    ProjectPolicies:
      type: object
      description: 'One project''s directly-assigned policy ids, plus its .plumber.yaml auto-policy offer when it has one (see projectsDirectPolicies).'
      required: [project_id, policy_ids]
      properties:
        project_id: { type: string, format: uuid }
        policy_ids:
          type: array
          items: { type: string, format: uuid }
        offer: { $ref: '#/components/schemas/PolicyOffer' }
    ProjectPoliciesList:
      type: object
      description: 'The 200 body for projectsDirectPolicies.'
      required: [projects]
      properties:
        projects:
          type: array
          items: { $ref: '#/components/schemas/ProjectPolicies' }
    PolicyOfferType:
      type: string
      enum: [match, new, unavailable]
      description: 'match: an existing policy already has the same controls as the file. new: no existing policy matches, a new one would be created. unavailable: the offer could not be computed this time.'
    PolicyOffer:
      type: object
      description: 'The .plumber.yaml auto-policy offer for one project (see projectsDirectPolicies). Present only for a project whose sync found a configuration file and that is still onboardable.'
      required: [type]
      properties:
        type: { $ref: '#/components/schemas/PolicyOfferType' }
        policy_id: { type: string, format: uuid, description: 'Present when type is match: the existing policy whose controls equal the file''s.' }
        policy_name: { type: string, description: 'Present when type is match: that policy''s own name.' }
        proposed_name: { type: string, description: 'Present when type is new: the name a save would create the policy under, "Custom - {project path}".' }
        controls:
          type: array
          items: { type: string }
          description: 'Present when type is new: the control types the file would enable, for preview only - never their configuration.'
        reason:
          type: string
          description: 'Present when type is unavailable: a plain-language explanation (file not found, oversized, unreachable, rate-limited, internal error, or a file that was read but declares no controls). Never blocks onboarding.'
    CatalogRepoRegisterRequest:
      type: object
      description: 'Body of registerCatalogRepo.'
      required: [project_id]
      properties:
        project_id:
          type: integer
          description: 'The repository''s id on the org''s connected git provider.'
    CatalogRepo:
      type: object
      description: 'One repository registered in the org''s catalog.'
      required: [id, project_id, project_path, name, namespace, default_branch, visibility, ci_conf_path, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: integer, description: 'The repository''s id on the org''s connected git provider.' }
        project_path: { type: string, description: 'The repository''s full path (namespace + name) on its git provider.' }
        name: { type: string }
        namespace: { type: string, description: 'The repository''s parent group/namespace path.' }
        default_branch: { type: string }
        visibility: { type: string, description: 'The repository''s visibility on its git provider (e.g. private, internal, public).' }
        ci_conf_path: { type: string, description: 'The repository''s configured CI configuration file path; empty string when it uses its provider''s default.' }
        last_activity_at: { type: string, format: date-time, nullable: true, description: 'The repository''s last known activity on its git provider as of registration time.' }
        last_sync_status:
          allOf: [{ $ref: '#/components/schemas/CatalogSyncStatus' }]
          nullable: true
          description: 'The status of the latest scan attempt, whatever its outcome; null when no scan has ever been recorded. Present on listCatalogRepos only, absent from the registerCatalogRepo response.'
        last_synced_at: { type: string, format: date-time, nullable: true, description: 'When the scan attempt named by last_sync_status was last updated (its terminal timestamp for a finished attempt, equal to its start for one still pending). Null under the same conditions as last_sync_status.' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CatalogRepoList:
      type: object
      description: 'The listCatalogRepos response envelope: one page of CatalogRepo rows.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogRepo' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    CatalogRepoRegistered:
      type: object
      description: 'The 201 body for registerCatalogRepo: the newly registered repository plus the id of its first scan.'
      required: [repo, sync_id]
      properties:
        repo: { $ref: '#/components/schemas/CatalogRepo' }
        sync_id: { type: string, format: uuid }
    CatalogSyncScheduled:
      type: object
      description: 'The 202 body for rescanCatalogRepo: the id of the newly scheduled scan.'
      required: [sync_id]
      properties:
        sync_id: { type: string, format: uuid }
    CatalogSyncStatus:
      type: string
      description: 'The frozen scan-status vocabulary: a scan starts pending and moves to exactly one terminal value.'
      enum: [pending, done, failed]
    CatalogSyncVersionError:
      type: object
      description: 'One version-level failure recorded by a scan.'
      required: [version, error]
      properties:
        version: { type: string }
        error: { type: string }
    CatalogSyncAnalyzeEntry:
      type: object
      description: 'One template-level entry of a scan''s report.'
      required: [templatePath, templateError, versionsError]
      properties:
        templatePath: { type: string }
        templateError: { type: string, description: 'Empty string when this template scanned cleanly. Otherwise a non-fatal note (e.g. "labels truncated to 50 of 200") when the template was still cataloged, or the fatal reason (size limit, an invalid path or icon, missing metadata) when no template record was produced; per-version failures (size limit, traversal or containment) are in versionsError.' }
        versionsError:
          type: array
          items: { $ref: '#/components/schemas/CatalogSyncVersionError' }
    CatalogSync:
      type: object
      description: 'One scan attempt recorded for a repository.'
      required: [id, repo_id, status, analyze, error, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        repo_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/CatalogSyncStatus' }
        analyze:
          type: array
          items: { $ref: '#/components/schemas/CatalogSyncAnalyzeEntry' }
          description: 'Per-template scan report; empty while the scan is still pending.'
        error: { type: string, description: 'A short diagnostic when status is failed; empty string otherwise.' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CatalogSyncList:
      type: object
      description: 'The listCatalogSyncs response envelope: one page of CatalogSync rows.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogSync' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    CatalogTemplateVersion:
      type: object
      description: 'One version cataloged for a template.'
      required: [tag, git_tag, conf_path, readme_path, changelog_path, stages, images, created_at, updated_at]
      properties:
        tag: { type: string, description: 'The version tag as published in the catalog (e.g. a semantic version, or "latest").' }
        git_tag: { type: string, description: 'The underlying git tag this version was scanned from.' }
        conf_path: { type: string, description: 'Path to this version''s CI configuration file within the repository.' }
        readme_path: { type: string, description: 'Path to this version''s README, empty string when it has none.' }
        changelog_path: { type: string, description: 'Path to this version''s changelog, empty string when it has none.' }
        stages:
          type: array
          items: { type: string }
          description: 'The CI stages this version declares.'
        images:
          type: array
          items: { type: string }
          description: 'The container images this version references.'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CatalogTemplateRepoRef:
      type: object
      description: 'Minimal identity of the repository a template was cataloged from: enough to locate it on its git provider, not the full CatalogRepo entity.'
      required: [id, project_path, name, namespace, default_branch, provider, instance]
      properties:
        id: { type: string, format: uuid }
        project_path: { type: string, description: 'The repository''s full path (namespace + name) on its git provider.' }
        name: { type: string }
        namespace: { type: string, description: 'The repository''s parent group/namespace path.' }
        default_branch: { type: string }
        provider: { type: string }
        instance: { type: string }
    CatalogTemplateSummary:
      type: object
      description: 'One row of the templates browse list (see listCatalogTemplates), carrying its owning repository, every known version, and its labels.'
      required: [id, path, description, icon, license, deprecated, labels, repo, versions]
      properties:
        id: { type: string, format: uuid }
        path: { type: string, description: 'The template''s file path within its repository.' }
        description: { type: string }
        icon: { type: string, description: 'Empty string when the template declares none.' }
        license: { type: string, description: 'Empty string when the template declares none.' }
        deprecated: { type: boolean }
        labels:
          type: array
          items: { type: string }
          description: 'Label names currently attached to this template.'
        repo: { $ref: '#/components/schemas/CatalogTemplateRepoRef' }
        versions:
          type: array
          items: { $ref: '#/components/schemas/CatalogTemplateVersion' }
          description: 'Ordered by version ranking: tags that parse as a version (a leading "v" is tolerated; CalVer and four-part forms are accepted) sort first, descending by version; tags that do not parse follow, newest-created first; the "latest" alias always sorts last.'
    CatalogTemplateDetail:
      type: object
      description: 'The full getCatalogTemplateDetail body: the same shape as CatalogTemplateSummary, with every known version.'
      required: [id, path, description, icon, license, deprecated, labels, repo, versions]
      properties:
        id: { type: string, format: uuid }
        path: { type: string, description: 'The template''s file path within its repository.' }
        description: { type: string }
        icon: { type: string, description: 'Empty string when the template declares none.' }
        license: { type: string, description: 'Empty string when the template declares none.' }
        deprecated: { type: boolean }
        labels:
          type: array
          items: { type: string }
          description: 'Label names currently attached to this template.'
        repo: { $ref: '#/components/schemas/CatalogTemplateRepoRef' }
        versions:
          type: array
          items: { $ref: '#/components/schemas/CatalogTemplateVersion' }
          description: 'Ordered by the same version ranking as CatalogTemplateSummary.versions.'
    CatalogTemplateList:
      type: object
      description: 'The listCatalogTemplates response envelope: one page of CatalogTemplateSummary rows plus pagination metadata.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogTemplateSummary' }
        total: { type: integer, description: 'Total matching rows under the current filters, across every page.' }
        page: { type: integer }
        per_page: { type: integer }
    CatalogComponentSummary:
      type: object
      description: 'One row of the components browse list (see listCatalogComponents): a CI/CD catalog resource of the org''s instance.'
      required: [id, full_path, name, latest_version, versions, web_path, seen_at, namespace, verification_level, components, version_components]
      properties:
        id: { type: string, format: uuid }
        full_path: { type: string, description: 'The resource''s full path on its git provider.' }
        name: { type: string }
        latest_version: { type: string, description: 'The highest-ranked known version tag (the same ordering as CatalogTemplateSummary.versions); empty string when the resource has no known versions.' }
        versions:
          type: array
          items: { type: string }
          description: 'Every known version tag, ranked; an empty array (never null) when the resource has none.'
        web_path: { type: string }
        seen_at: { type: string, format: date-time, description: 'When the platform last saw this resource in the instance''s catalog listing.' }
        namespace: { type: string, description: 'The group path this resource lives under, derived from full_path (everything before the last "/"); empty string when the resource is in no group. This is what scope=mine matches against.' }
        verification_level:
          type: string
          description: 'GitLab''s own verificationLevel for this resource, served verbatim (an unknown value is passed through). Empty string means the instance did not answer the field (an older GitLab); on such an instance scope=gitlab_maintained returns nothing, while scope=official is unaffected.'
        components:
          type: array
          items: { type: string }
          description: 'The component names the latest version publishes (version_components[0].components, never a union across versions). A complete requirement for the pipelineMustIncludeComponent control is `<full_path>/<component>`. Always present; `[]` when the resource has no versions or the instance did not answer the components (see verification_level).'
        version_components:
          type: array
          items: { $ref: '#/components/schemas/CatalogComponentVersion' }
          description: 'The component names per version, in the same ranked order as versions: version_components[i].version equals versions[i], so version_components[0] always describes latest_version. Always present; `[]` when the resource has no versions.'
    CatalogComponentVersion:
      type: object
      description: 'One entry of CatalogComponentSummary.version_components: the component names one version of a catalog resource publishes.'
      required: [version, components]
      properties:
        version: { type: string, description: 'The version tag, the same string that appears at the matching index of CatalogComponentSummary.versions.' }
        components:
          type: array
          items: { type: string }
          description: 'The component names this version publishes, in the order GitLab listed them. Always present; `[]` when the instance did not answer the components for this resource.'
    CatalogComponentList:
      type: object
      description: 'The listCatalogComponents response envelope: one page of CatalogComponentSummary rows plus pagination metadata.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogComponentSummary' }
        total: { type: integer, description: 'Total matching rows under the current filters, across every page.' }
        page: { type: integer }
        per_page: { type: integer }
    CatalogLabel:
      type: object
      description: 'One label of the org''s catalog vocabulary.'
      required: [name, displayed, created_at, updated_at]
      properties:
        name: { type: string }
        displayed: { type: boolean, description: 'Whether this label is currently shown to browsers of the catalog.' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CatalogLabelList:
      type: object
      description: 'The listCatalogLabels response envelope: one page of the org''s label vocabulary.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CatalogLabel' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    CatalogLabelCreateRequest:
      type: object
      description: 'Body of createCatalogLabel.'
      required: [name]
      properties:
        name: { type: string, description: '1 to 50 characters: letters, numbers, spaces, hyphens and underscores only. Surrounding whitespace is trimmed before validation.' }
    CatalogLabelUpdateRequest:
      type: object
      description: 'Body of updateCatalogLabel.'
      required: [displayed]
      properties:
        displayed: { type: boolean, description: 'Whether this label should be shown to browsers of the catalog.' }

    Role:
      type: string
      enum: [none, member, maintainer, org_admin]
      description: 'The caller''s derived Plumber role, ordered none < member < maintainer < org_admin.'
    CurrentUserRole:
      type: object
      description: 'The myRole response: the caller''s own derived role.'
      required: [role]
      properties:
        role: { $ref: '#/components/schemas/Role' }
    CurrentUser:
      type: object
      description: 'The me response: the caller''s own session identity, plus the org-wide UI preferences served to every role (org_ui).'
      required: [name, role, id, provider, instance, org_ui]
      properties:
        name: { type: string, description: 'The caller''s provider display name, as of their last login.' }
        username:
          type: string
          nullable: true
          description: 'The caller''s provider username; null until their next login captures it.'
        avatar_url: { type: string, nullable: true, description: 'The caller''s provider avatar URL, as of their last login; null (never an empty string) when none is on file.' }
        role: { $ref: '#/components/schemas/Role' }
        id:
          type: string
          format: uuid
          description: 'The caller''s own platform user id.'
        provider:
          type: string
          description: 'The git provider of the instance the caller is logged into (e.g. gitlab); the same provider value LoginInstance/MemberInstance carry for that instance.'
        instance:
          type: string
          description: 'The name of the git instance the caller is logged into (e.g. default); the same instance value LoginInstance/MemberInstance carry.'
        instance_id:
          type: string
          format: uuid
          nullable: true
          description: 'The id of the git instance named by provider/instance; null when no matching instance exists (e.g. it was renamed or removed since the caller''s session began).'
        org_ui:
          $ref: '#/components/schemas/OrgUi'
    OrgUi:
      type: object
      description: 'Org-wide UI preferences, served on the me response to every role. Read-only here: each field is derived from the org settings, which an OrgAdmin or Maintainer writes through updateOrgSettings.'
      required: [community_link_hidden]
      properties:
        community_link_hidden:
          type: boolean
          description: 'Mirrors OrgSettings.community_link_hidden, served to every role so a client can hide the community (Discord) entry. False by default: the entry stays visible until an org hides it.'
    ProviderToken:
      type: object
      description: 'The myProviderToken response: the caller''s own current GitLab OAuth access token for their instance, freshly refreshed. Never cached by an intermediary (Cache-Control: no-store) and never logged.'
      required: [access_token, expires_at]
      properties:
        access_token:
          type: string
          description: 'The caller''s own plaintext GitLab OAuth access token, current as of this response, with the full scope of the user''s own OAuth grant.'
        expires_at:
          type: string
          format: date-time
          description: 'When access_token expires (RFC 3339); a client can re-call this endpoint before then rather than waiting for a provider 401.'
    ApiTokenCreateRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 100, description: 'A label the caller picks (the agent or machine this token is for); shown on the list and used as the config audit entity_label.' }
        expires_at: { type: string, format: date-time, nullable: true, description: 'Optional expiry, RFC 3339, must be in the future. Null or absent = no expiry (revocation is the only end).' }
    ApiToken:
      type: object
      required: [id, name, display_prefix, scope, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        display_prefix: { type: string, description: 'The first 12 characters of the token (plmb_ + 7), the only fragment ever readable again.' }
        scope: { type: string, enum: [read] }
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: 'Omitted from the response body entirely (never a literal JSON null) when the token has no expiry.'
        revoked_at: { type: string, format: date-time, nullable: true, description: 'Omitted (never a literal JSON null) while the token is still active.' }
        last_used_at: { type: string, format: date-time, nullable: true, description: 'Updated at most once per minute per token when the MCP endpoint accepts it. Omitted (never a literal JSON null) until the token is first used.' }
        created_at: { type: string, format: date-time }
    ApiTokenCreated:
      allOf:
        - $ref: '#/components/schemas/ApiToken'
        - type: object
          required: [token]
          properties:
            token: { type: string, description: 'The plaintext token, shown exactly once. Never logged, never stored, never retrievable again.' }
    ApiTokenList:
      type: object
      required: [items]
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/ApiToken' } }
    UserRef:
      type: object
      description: 'One row of getUsers'' UserRefs.items: a resolved platform user identity, without email (see getUsers).'
      required: [id, name]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, description: 'The user''s provider display name, as of their last login (the same value CurrentUser.name and AuditUserEntry.name carry).' }
        username:
          type: string
          nullable: true
          description: 'The user''s provider username; null until their next login captures it (the same convention as CurrentUser.username).'
        avatar_url: { type: string, nullable: true, description: 'The user''s provider avatar URL, as of their last login; null (never an empty string) when none is on file, the same convention as CurrentUser.avatar_url.' }
    UserRefs:
      type: object
      description: 'The getUsers response envelope: resolved identities for a batch of requested ids. Not the standard paginated list envelope: bounded by the ids parameter''s 100-entry cap, never paginated.'
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/UserRef' }
          description: 'One entry per requested id that resolves within the caller''s own org, in no guaranteed order. An unknown id, or one belonging to a different org, contributes no entry (see getUsers).'
    UpdateCheckStatus:
      type: string
      enum: [up_to_date, outdated, cannot_check]
      description: |-
        The result of the daily update check.

        - `up_to_date`: the running version is at or ahead of the latest published release.
        - `outdated`: the latest published release is strictly newer than the running version (`latest_version` is present).
        - `cannot_check`: no positive comparison could be made; `reason` names why: `stale_build` (the stored result was computed by a different build than the one serving it, served until this build's own check restamps it shortly after a deploy), `not_configured` (the check is disabled with `PLUMBER_UPDATE_CHECK_URL=off`, the air-gapped opt-out), `unreachable` (the manifest URL could not be reached), `malformed_manifest` (the response was not a valid `{"latest":"vX.Y.Z"}` document), or `dev_build` (the running binary is unstamped). Never `up_to_date` when the true state cannot be proven.
    UpdateCheck:
      type: object
      description: 'The last background update-check result, as stored (daily, and once at every boot) - never computed synchronously on the version request itself. A stored verdict (up_to_date or outdated) is only served by the build that computed it; any other build serves cannot_check/stale_build until its own boot tick has restamped the row. A stored cannot_check is served as stored.'
      required: [status]
      properties:
        status: { $ref: '#/components/schemas/UpdateCheckStatus' }
        latest_version: { type: string, description: 'Present only when status=outdated: the latest published release, verbatim from the manifest (e.g. "v0.5.0").' }
        checked_at: { type: string, format: date-time, description: 'When this result was last (re)computed by the daily tick. Absent only on a fresh install before the tick has ever run.' }
        reason:
          type: string
          description: 'Present only when status=cannot_check, naming why (see UpdateCheckStatus for the vocabulary). Not a closed enum: a future failure mode may add a value.'
    VersionInfo:
      type: object
      description: 'The getVersion response: this backend''s own build stamp plus the last update-check result.'
      required: [backend_version, git_sha, update_check]
      properties:
        backend_version: { type: string, description: 'The release version this binary was built with (e.g. "v0.4.38"), or "dev" for an unstamped build.' }
        git_sha: { type: string, description: 'The commit SHA this binary was built from, or "unknown" for an unstamped build.' }
        update_check: { $ref: '#/components/schemas/UpdateCheck' }
    AuthorizedMaintainerGroup:
      type: object
      description: 'One authorized-maintainer-group grant: any member of this group, at any provider access level above none, derives the Maintainer role.'
      required: [provider_group_id, path, created_at]
      properties:
        provider_group_id: { type: string, description: 'The group''s id on the org''s connected git provider.' }
        path: { type: string, description: 'The group''s path as last synced; resolved from what the platform already has on file, never a live provider call.' }
        created_at: { type: string, format: date-time }
    AuthorizedMaintainerGroupList:
      type: object
      description: 'The listAuthorizedMaintainerGroups response envelope: one page of AuthorizedMaintainerGroup rows, offset-paginated.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AuthorizedMaintainerGroup' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }
    AuthorizedMaintainerGroupCreateRequest:
      type: object
      description: 'Body of createAuthorizedMaintainerGroup.'
      required: [provider_group_id]
      properties:
        provider_group_id: { type: string, description: 'Must already be known to this instance (seen by a previous sync).' }
    SyncedGroup:
      type: object
      description: 'One row of listSyncedGroups: a git group the platform has synced for the caller''s org, across every connected instance (org-wide), as last synced.'
      required: [provider_group_id, full_path, name, provider, instance]
      properties:
        provider_group_id: { type: string, description: 'The group''s id on the org''s connected git provider; the same value createAuthorizedMaintainerGroup''s provider_group_id accepts.' }
        full_path: { type: string, description: 'The group''s full path, as last synced.' }
        name: { type: string, description: 'The group''s display name, as last synced.' }
        provider:
          type: string
          description: 'The git provider this group was synced from (e.g. gitlab). The listing is org-wide across every connected instance; this field tells which instance a row belongs to.'
        instance:
          type: string
          description: 'The connected git instance name this group was synced from: the same (provider, instance) pairing every other instance-scoped row on this contract carries.'
    SyncedGroupList:
      type: object
      description: 'The listSyncedGroups response envelope: one page of SyncedGroup rows, org-wide across every connected instance, offset-paginated like AuthorizedMaintainerGroupList.'
      required: [items, page, per_page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SyncedGroup' }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer, description: 'Total matching rows across every page.' }

    AuditAnalysisKind:
      type: string
      description: 'Discriminates one listAuditAnalyses row: run (a pushed CLI analysis result) or sync (an asset-sync attempt).'
      enum: [run, sync]
    AuditSyncRunStatus:
      type: string
      description: 'The status of a sync run: queued while no worker has claimed it yet, running while it is in progress, done on a clean finish, failed otherwise.'
      enum: [queued, running, done, failed]
    AuditAnalysisEntry:
      type: object
      description: 'One row of listAuditAnalyses''s merged, newest-first page. Only the fields for its own kind are populated; the other kind''s fields are absent.'
      required: [kind, id]
      properties:
        kind: { $ref: '#/components/schemas/AuditAnalysisKind' }
        id: { type: string, format: uuid, description: 'The underlying run''s or sync run''s own id.' }
        project_path: { type: string, description: 'Present when kind is run: the pushing project''s full provider path.' }
        project_name:
          type: string
          description: 'Present when kind is run: the pushing project''s display name (the same value as InventoryProject.name); "" for a project not synced since this field was added, filled on its next sync.'
        branch: { type: string, description: 'Present when kind is run: the analyzed branch; empty for a tag-triggered push.' }
        tag: { type: string, description: 'Present when kind is run: the analyzed tag; empty for a branch-triggered push.' }
        sha: { type: string, description: 'Present when kind is run: the analyzed commit sha.' }
        pipeline_id: { type: string, description: 'Present when kind is run: the CI pipeline id that produced this push.' }
        job_id: { type: string, description: 'Present when kind is run: the CI job id that produced this push.' }
        score_letter: { type: string, description: 'Present when kind is run: the run''s score letter (Critical malus included).' }
        score_points: { type: integer, description: 'Present when kind is run: the run''s final points, the displayed score.' }
        raw_points:
          type: integer
          description: 'Present when kind is run: the run''s signed raw points (100 minus capped losses, no floor, no malus), kept for history only.'
        score_profile: { type: string, description: 'Present when kind is run: the scoring formula of the run''s score (`scoring-v3`, `scoring-v4`, or `mixed`).' }
        collection_degraded:
          type: boolean
          description: 'Present when kind is run: the CLI''s collection.degraded signal. True means the analysis ran on incomplete collected data and the CLI withheld the score from its operator; the platform stores and serves the score anyway, with this flag. False for a run pushed before this signal existed.'
        collection_missing_fields: { type: array, items: { type: string }, description: 'Present when kind is run: the CLI''s collection.missing_fields signal, naming which collected fields were missing. Empty when collection was clean, or for a run pushed before this signal existed.' }
        evaluation_reason:
          type: string
          description: 'Present when kind is run: why this run evaluated nothing: `no_policy` (no policy resolved for the project, so the CLI had nothing to run) or `policies_not_applicable` (policies resolved, but none applied). Empty string for an ordinary run, for a run pushed before this signal existed, and for an unrecognized pushed value. A nothing-evaluated run carries score_letter "" and score_points 0. The set may gain values later: treat an unrecognized value as "nothing was evaluated, cause unknown", never as an error.'
        received_at: { type: string, format: date-time, description: 'Present when kind is run: when the platform received this push; the newest-first sort key for run entries.' }
        instance: { type: string, description: 'Present when kind is sync: the git instance slug the sync ran against.' }
        scheduled: { type: boolean, description: 'Present when kind is sync: true for the nightly automatic sync, false for a manually triggered one.' }
        status: { allOf: [ { $ref: '#/components/schemas/AuditSyncRunStatus' } ], description: 'Present when kind is sync.' }
        error: { type: string, description: 'Present when kind is sync: a short diagnostic when status is failed; empty string otherwise.' }
        started_at: { type: string, format: date-time, description: 'Present when kind is sync: when the sync attempt started; the newest-first sort key for sync entries.' }
        finished_at: { type: string, format: date-time, nullable: true, description: 'Present when kind is sync: when the sync attempt completed (successfully or not); null while status is running.' }
        actor_id:
          type: string
          format: uuid
          description: 'On a sync entry, the platform user id of the person who triggered the manual sync. Omitted (the key is absent, never null) on every run entry, on a scheduled or activity-driven sync, and when that user was later deleted.'
        actor_avatar_url:
          type: string
          description: 'That user''s provider avatar URL, as of their last login. Omitted whenever actor_id is omitted, and when the user has no avatar on file; never null, never an empty string.'
    AuditAnalysisList:
      type: object
      description: 'The listAuditAnalyses response envelope: one merged, newest-first page of AuditAnalysisEntry rows, with each kind''s own total (runs_total, syncs_total) plus the combined `total`.'
      required: [items, runs_total, syncs_total, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AuditAnalysisEntry' }
        runs_total: { type: integer, description: 'Total matching run rows received in the last 30 days, across every page, independent of per_page/page.' }
        syncs_total: { type: integer, description: 'Total matching sync-run rows started in the last 30 days, across every page, independent of per_page/page.' }
        total: { type: integer, description: 'runs_total + syncs_total: the standard combined total, for a caller that only needs one count.' }
        page: { type: integer }
        per_page: { type: integer }
    AuditFixEntry:
      type: object
      description: 'One row of listAuditFixes: org-wide fix history (unlike listFixHistory, not limited to the caller''s visible projects).'
      required: [id, issue_id, control_type, project_path, project_name, status, error, fixer_version, created_at]
      properties:
        id: { type: string, format: uuid }
        issue_id:
          type: string
          format: uuid
          description: 'The issue this fix targeted; always present.'
        control_type: { type: string, description: 'The referenced issue''s control_type.' }
        project_path: { type: string, description: 'The fixed project''s full provider path.' }
        project_name:
          type: string
          description: 'The fixed project''s display name (the same value as InventoryProject.name, cached at sync, never a live provider call); required but "" for a project not synced since this field was added, filled on its next sync.'
        status: { $ref: '#/components/schemas/FixStatus' }
        error: { type: string, description: 'The bounded diagnostic string when status is failed; empty string otherwise.' }
        fixer_version: { type: string }
        created_at: { type: string, format: date-time }
        actor_name:
          type: string
          nullable: true
          description: 'The triggering user''s display name; null for a system-triggered fix, or when that user was later deleted.'
        actor_id:
          type: string
          format: uuid
          description: 'The triggering user''s platform user id, the value the `actor` filter matches on. Omitted (the key is absent, never null) for a system-triggered fix or when that user was later deleted, exactly when actor_name is null.'
        actor_avatar_url:
          type: string
          description: 'The triggering user''s provider avatar URL, as of their last login. Omitted (the key is absent) whenever actor_id is omitted, and when that user has no avatar on file; never null, never an empty string.'
    AuditFixList:
      type: object
      description: 'The listAuditFixes response envelope: one page of AuditFixEntry rows.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AuditFixEntry' }
        total: { type: integer, description: 'Total matching rows across every page.' }
        page: { type: integer }
        per_page: { type: integer }
    AuditUserEntry:
      type: object
      description: 'One row of listAuditUsers: a platform user of the org.'
      required: [id, name, email, provider, instance]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string }
        provider: { type: string }
        instance: { type: string }
        last_login: { type: string, format: date-time, nullable: true, description: 'Refreshed at each login; null if this user has never logged in.' }
        last_activity: { type: string, format: date-time, nullable: true, description: 'Updated on authenticated use (throttled); null if this user has never been active beyond logging in.' }
        avatar_url:
          type: string
          description: 'The user''s provider avatar URL, as of their last login (the same value UserRef.avatar_url and IssueSummary.owner_avatar_url serve). Omitted (the key is absent, never null, never an empty string) when no avatar is on file.'
    AuditUserList:
      type: object
      description: 'The listAuditUsers response envelope: one page of AuditUserEntry rows.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AuditUserEntry' }
        total: { type: integer, description: 'Total matching rows across every page.' }
        page: { type: integer }
        per_page: { type: integer }

    AuditConfigKind:
      type: string
      description: 'The entity_kind vocabulary of listAuditConfig: which class of config surface a row describes. project_badge is PUT /projects/{id}/badge, licence is PUT /settings/licence, ai is PUT /settings/ai, official_component is PUT /projects/{id}/official-component.'
      enum: [policy, portfolio, assignment, instance, token, org_settings, smtp, schedule, authorization_group, catalog_repo, catalog_label, project_badge, onboarding_status, licence, ai, official_component]
    AuditConfigAction:
      type: string
      description: 'The action vocabulary of listAuditConfig: what happened to the entity. rotated is used only for token; every other kind uses created/updated/deleted.'
      enum: [created, updated, deleted, rotated]
    AuditConfigEntry:
      type: object
      description: 'One row of listAuditConfig: a single config change, captured at write time (facts, not diffs).'
      required: [id, entity_kind, action, entity_label, created_at]
      properties:
        id: { type: string, format: uuid }
        actor_name:
          type: string
          nullable: true
          description: 'The acting user''s display name; null for a system-triggered write, or when that user was later deleted.'
        actor_id:
          type: string
          format: uuid
          description: 'The acting user''s platform user id, the value the `actor` filter matches on. Omitted (the key is absent, never null) for a system-triggered write or when that user was later deleted, exactly when actor_name is null.'
        actor_avatar_url:
          type: string
          description: 'The acting user''s provider avatar URL, as of their last login. Omitted (the key is absent) whenever actor_id is omitted, and when that user has no avatar on file; never null, never an empty string.'
        entity_kind: { $ref: '#/components/schemas/AuditConfigKind' }
        entity_id: { type: string, nullable: true, description: 'The mutated entity''s own id (a policy/portfolio/instance/catalog-repo uuid as text, the authorized group''s provider_group_id, or the catalog label''s own name); null for a settings singleton (org_settings, smtp, schedule), which carries no entity id of its own.' }
        action: { $ref: '#/components/schemas/AuditConfigAction' }
        entity_label:
          type: string
          description: 'A short human-readable name captured at write time: the new value that write just wrote (e.g. the policy''s new name after a rename), never the prior one, so reading the trail in order reconstructs rename history. Never updated later, and kept after the entity is deleted.'
        created_at: { type: string, format: date-time }
    AuditConfigList:
      type: object
      description: 'The listAuditConfig response envelope: one page of AuditConfigEntry rows, newest first, windowed to the last 30 days.'
      required: [items, total, page, per_page]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AuditConfigEntry' }
        total: { type: integer, description: 'Total matching rows created in the last 30 days, across every page.' }
        page: { type: integer }
        per_page: { type: integer }

    SmtpTlsMode:
      type: string
      description: 'How to negotiate TLS when connecting to the SMTP server: `none` (no TLS), `starttls` (plaintext connect, then upgrade - typical for port 587), `tls` (implicit TLS from the first byte - typical for port 465).'
      enum: [none, starttls, tls]
    SmtpSettings:
      type: object
      description: 'The org SMTP settings document (getReportsSmtpSettings/updateReportsSmtpSettings): the org-wide outbound mail server used to send the scheduled report. The password is never part of this shape - only whether one is configured.'
      required: [host, port, username, password_set, from_address, tls_mode]
      properties:
        host: { type: string, description: 'SMTP server hostname. Empty string means not configured yet.' }
        port: { type: integer, description: 'SMTP server port (e.g. 587 for STARTTLS, 465 for implicit TLS, 25 for none). 0 means not configured yet.' }
        username: { type: string, description: 'SMTP auth username. Empty when the relay needs no authentication.' }
        password_set: { type: boolean, description: 'True when a password is configured. The password value itself is never returned by any endpoint.' }
        from_address: { type: string, description: 'The From: address the scheduled report is sent from.' }
        tls_mode: { $ref: '#/components/schemas/SmtpTlsMode' }
    UpdateSmtpSettingsRequest:
      type: object
      description: 'Body of updateReportsSmtpSettings: a full replacement of the org SMTP configuration, tested with a real connection before anything is stored (see the endpoint description).'
      required: [host, port, username, password, from_address, tls_mode]
      properties:
        host: { type: string, description: 'SMTP server hostname.' }
        port: { type: integer, description: 'SMTP server port, 1-65535.' }
        username: { type: string, description: 'SMTP auth username. Leave empty (together with password) for an unauthenticated relay.' }
        password: { type: string, description: 'Plaintext candidate password, tested then sealed at rest; never echoed back. Required on every write (this endpoint never partially updates a stored configuration) - leave empty (together with username) for an unauthenticated relay.' }
        from_address: { type: string, description: 'The From: address the scheduled report is sent from. Must look like an email address.' }
        tls_mode: { $ref: '#/components/schemas/SmtpTlsMode' }

    ReportExportScope:
      type: string
      description: 'The export scopes: `organization` (everything the caller can see, org-wide), `project`, `portfolio`, `policy` (a single named scope, identified by `scope_id`).'
      enum: [organization, project, portfolio, policy]
    ReportExportStatus:
      type: string
      description: '`pending` while generating (the only status a fresh createReportExport response carries), `ready` once the zip is stored and downloadable, `failed` when generation could not complete (`error` then carries the reason).'
      enum: [pending, ready, failed]
    CreateReportExportRequest:
      type: object
      description: 'Body of createReportExport.'
      required: [scope]
      properties:
        scope: { $ref: '#/components/schemas/ReportExportScope' }
        scope_id: { type: string, format: uuid, nullable: true, description: 'The scoped project/portfolio/policy id. Required for project/portfolio/policy scope; must be omitted (or null) for organization scope.' }
    ReportExport:
      type: object
      description: 'One export request/attempt (createReportExport/getReportExport). Carries no byte content itself - getReportExport serves the actual zip as a separate application/zip response once status is ready.'
      required: [id, scope, status, error, byte_size, created_at, expires_at]
      properties:
        id: { type: string, format: uuid }
        scope: { $ref: '#/components/schemas/ReportExportScope' }
        scope_id: { type: string, format: uuid, nullable: true, description: 'Null for organization scope.' }
        status: { $ref: '#/components/schemas/ReportExportStatus' }
        error: { type: string, description: 'The failure reason when status is failed; empty otherwise.' }
        byte_size: { type: integer, format: int64, description: 'The generated zip''s size in bytes. 0 until status is ready.' }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, description: 'When this export stops being downloadable (7 days from creation) and a background sweep removes the row - see getReportExport''s own description.' }

    ReportSchedule:
      type: object
      description: 'The org-wide scheduled report (getReportSchedule/updateReportSchedule): cron, recipients, paused, who last saved it, and the outcome of its last run. See updateReportSchedule for the validation rules and for what a successful run does and does not prove about delivery. There is exactly one schedule per org and no delete endpoint: to stop sending, set paused to true.'
      required: [cron, recipients, paused, last_run_status, last_run_error, updated_by, updated_at, timezone]
      properties:
        cron:
          type: string
          description: 'Standard 5-field cron expression. Empty only while paused is true.'
        recipients: { type: array, items: { type: string }, description: 'Email addresses the report is sent to.' }
        paused:
          type: boolean
          description: 'True by default and for a never-configured org; a paused schedule never runs, whatever its cron says.'
        last_run_at: { type: string, format: date-time, nullable: true, description: 'When the schedule last ran (attempted a send), success or failure alike. Null before the first run; never set for a run skipped because the schedule was paused.' }
        last_run_status: { type: string, description: '"success" or "failed" once the schedule has run at least once; empty before the first run.' }
        last_run_error: { type: string, description: 'The failure reason when last_run_status is failed; names a rejected recipient and the relay''s reason when that is what failed, never the SMTP credential. Empty otherwise.' }
        updated_by:
          type: string
          description: 'Display name or email of the OrgAdmin who last saved this schedule (name, then email, then a bare id). Empty for a never-configured schedule, or once that user''s account has been removed. Display only.'
        updated_at: { type: string, format: date-time, description: 'When the schedule was last saved (or created, if never edited since).' }
        timezone:
          type: string
          description: 'The IANA timezone cron is interpreted against, echoed from the org settings (the timezone field of updateOrgSettings); not writable here. DST: on the day a timezone springs forward, a cron target inside the skipped hour does not fire that day; on the day it falls back, a target inside the repeated hour fires twice. Avoid scheduling inside 01:00-03:00 local in a DST-observing timezone if a once-a-year skipped or doubled report is unacceptable.'
        next_run_at:
          type: string
          format: date-time
          nullable: true
          description: 'The next time this schedule fires, computed from cron and timezone. May be at or before the current time: that is the overdue signal for a schedule whose runs are delayed. Omitted from the response entirely (never a literal JSON null) when the schedule is paused or has never been configured; check for the key''s presence.'
    UpdateReportScheduleRequest:
      type: object
      description: 'Body of updateReportSchedule: a full replacement of the org''s schedule, validated entirely at save (see the endpoint description).'
      required: [cron, recipients, paused]
      properties:
        cron: { type: string, description: 'Standard 5-field cron expression. May be empty only when paused is true.' }
        recipients: { type: array, items: { type: string }, description: 'Email addresses to send the report to. Each must look like an email address; must be non-empty when paused is false.' }
        paused:
          type: boolean
          description: 'False activates the schedule (requires a valid cron and at least one recipient); true stops it from firing while keeping cron and recipients stored. Required on every write: omitting it returns 400 naming the field.'
    DependencyKind:
      type: string
      enum: [component, template, image, service, runner_tag, variable]
      description: |-
        The pipeline resource kinds the dependencies graph knows; a resource is exactly one of these six.

        - `component`: a CI component include, keyed `<source project path>/<component name>`.
        - `template`: a project include (keyed `<project path>:<location>`), a remote include (keyed by its URL) or a GitLab-shipped template (keyed `template:<name>`). A local include is never a resource: it is the project's own file.
        - `image`: a container image a job runs in, keyed `<registry>/<name>`. The tag or digest is carried on each usage edge, not on the key.
        - `service`: the same shape as `image`, for an image attached to a job as a service.
        - `runner_tag`: a job's own `tags:` entry, keyed by the tag string.
        - `variable`: a CI/CD variable name, read from the project on the provider rather than from the push. Its value is never stored and never served.
    DependencyVersionSpread:
      type: object
      description: 'One version of a resource with the number of visible projects using that exact version.'
      required: [version, consumer_count]
      properties:
        version:
          type: string
          description: 'The version as the CLI reported it: an include''s `version`, an image''s tag or digest. Empty for a kind that carries no version (runner_tag, variable) and for a use the CLI reported without one.'
        consumer_count:
          type: integer
          description: 'Distinct projects visible to the caller using the resource at this exact version.'
    DependencyResourceSummary:
      type: object
      description: |-
        One row of the dependencies list. Every count on it is limited to the projects the caller can read on the provider, so two callers can see different numbers for the same resource. The key and every version are the CLI's own strings.
      required: [id, kind, key, consumer_count, edge_count, version_spread, other_versions_count, outdated_count, last_seen]
      properties:
        id: { type: string, format: uuid, description: 'The resource node''s own platform id, the path parameter of getDependency.' }
        kind: { $ref: '#/components/schemas/DependencyKind' }
        key:
          type: string
          description: 'The resource key, derived from what the CLI pushed and stored as is; the platform never rewrites a key, normalises a path or resolves an alias.'
        consumer_count: { type: integer, description: 'Distinct visible projects using this resource; a resource is listed only when this is at least 1.' }
        edge_count: { type: integer, description: 'Usage edges over those same visible projects. Higher than consumer_count when one project uses the resource in several jobs (an image pulled by three jobs is three edges, one consumer).' }
        version_spread:
          type: array
          description: 'The distinct versions in use across the visible projects with their consumer counts, most used first, at most 20 entries; the consumers of versions beyond the twentieth are summed into other_versions_count.'
          items: { $ref: '#/components/schemas/DependencyVersionSpread' }
        other_versions_count:
          type: integer
          description: 'The summed consumer counts of the versions not listed in version_spread (beyond the twentieth distinct version); a project using three unlisted versions counts once per version. 0 when every version in use is listed.'
        outdated_count:
          type: integer
          description: 'Visible edges whose pushed `up_to_date` flag is false; an edge whose bill carried no such flag does not contribute.'
        last_seen: { type: string, format: date-time, description: 'The most recent moment any visible project was seen using this resource - the latest last_seen of the edges this row counts.' }
    DependencyResourceList:
      type: object
      description: 'The listDependencies response envelope: one keyset page of DependencyResourceSummary rows plus pagination metadata, the same shape as InventoryList.'
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/DependencyResourceSummary' }
        total: { type: integer, description: 'Total visible resources of that kind matching every filter, across every page; identical for every page of the same walk.' }
        next_cursor:
          type: string
          nullable: true
          description: 'Opaque cursor for the next page''s `after` parameter; null when this is the last page. Never parse it client-side.'
    DependencyConsumer:
      type: object
      description: 'One project''s use of one resource, as getDependency serves it. Only projects visible to the caller are listed; every value the CLI pushed is served as pushed.'
      required: [project_id, project_path, project_name, job, version, attrs, first_seen, last_seen]
      properties:
        project_id: { type: string, format: uuid, description: 'The consuming project, the same id GET /projects/{id} takes.' }
        project_path: { type: string, description: 'The consuming project''s provider path.' }
        project_name: { type: string, description: 'The consuming project''s display name as the provider reports it; empty when the sync has never named it.' }
        job:
          type: string
          description: 'The job this use belongs to, for the per-job kinds (image, service, runner_tag); empty for an include and for a variable, which are pipeline-level.'
        version: { type: string, description: 'The version of this particular use: the include''s `version`, the image''s tag or digest. Empty for runner_tag and variable, and for a use the CLI reported without one.' }
        attrs:
          type: object
          additionalProperties: true
          description: 'The flags reported for this use, as pushed: `latest_version`, `up_to_date`, `from_catalog`, `nested`, `overridden`, `overridden_jobs`, `authorized`, `forbidden_tag` on a pushed edge, reported by the CLI; and `masked`, `protected`, `hidden`, `environment`, `type`, `origin` on a variable edge, read from the provider. Every boolean is three-state (true, false, absent): a flag the bill did not carry is absent. A variable''s value is never included.'
        first_seen:
          type: string
          format: date-time
          description: 'When this project was first seen using the resource this way; a later push that still names the use does not reset it.'
        last_seen: { type: string, format: date-time, description: 'When this project was last seen using the resource this way: the time of the push (or of the variable refresh from the provider) that most recently named it.' }
        run_id:
          type: string
          format: uuid
          nullable: true
          description: 'The run whose bill of materials last carried this use; null for a variable, which belongs to no run.'
    DependencyNeighbourhoodNode:
      type: object
      description: 'One node of a resource''s neighbourhood graph.'
      required: [id, label, kind]
      properties:
        id: { type: string, description: 'Node identity within this payload, the value the edges point at: the resource id for the resource node, the project id for a project node.' }
        label:
          type: string
          description: 'The text to draw on the node: the resource key for the resource node, the project path for a project node.'
        kind: { type: string, enum: [resource, project], description: 'Which side of the graph this node is: `resource` or `project`. Not a DependencyKind: the resource node''s own kind is on the detail itself.' }
        project_id:
          type: string
          format: uuid
          nullable: true
          description: 'The project this node stands for. Present on a project node, null on the resource node.'
        edge_count: { type: integer, nullable: true, description: 'How many usage edges this project has with the resource (several jobs of one project are several edges). Present on a project node, null on the resource node.' }
    DependencyNeighbourhoodEdge:
      type: object
      description: 'One edge of a resource''s neighbourhood graph: the resource to one consuming project, labelled with the version that project uses.'
      required: [from, to, version, outdated]
      properties:
        from: { type: string, description: 'The source node id, always the resource node in this version of the graph.' }
        to: { type: string, description: 'The target node id, a project node.' }
        version: { type: string, description: 'The version this project uses, as the CLI reported it. Empty for a kind that carries none; when one project uses several versions the edge carries the one of its most recent use.' }
        outdated:
          type: boolean
          nullable: true
          description: 'The CLI''s `up_to_date` flag, inverted: true when the bill said this use is not up to date, false when the bill carried no such flag (an image, service, runner_tag or variable edge never carries one). Never null.'
    DependencyNeighbourhood:
      type: object
      description: 'The graph a resource page draws: the resource plus one node per visible consuming project, and the edges between them. Capped at 200 project nodes; beyond that `truncated` is true. The cap counts consuming projects, not consumer edges.'
      required: [nodes, edges, truncated]
      properties:
        nodes:
          type: array
          items: { $ref: '#/components/schemas/DependencyNeighbourhoodNode' }
          description: 'The resource node plus at most 200 project nodes, most used first, so a truncated graph keeps the most-used projects.'
        edges:
          type: array
          items: { $ref: '#/components/schemas/DependencyNeighbourhoodEdge' }
          description: 'Exactly the edges of the nodes served: an edge never points at a node absent from `nodes`.'
        truncated: { type: boolean, description: 'True when the resource has more visible consuming projects than the 200-project cap, so the graph is a most-used subset; the consumers list still pages through every consumer edge.' }
    DependencyResourceDetail:
      description: |-
        The getDependency response: everything DependencyResourceSummary carries, plus the resource's own attributes, one page of its consumers, and the neighbourhood graph.

        Two counts: a consuming project is one distinct visible project, counted by `consumer_count`; a consumer edge is one use by one project in one job, counted by `edge_count` and `consumer_edges_total` and carried one per entry of `consumers[]`. A project pulling the same image in three jobs is one consuming project and three consumer edges.

        Everything on it is limited to the caller's visibility and every CLI value is served as pushed.
      allOf:
        - $ref: '#/components/schemas/DependencyResourceSummary'
        - type: object
          required: [attrs, consumers, consumer_edges_total, consumers_page, consumers_per_page, neighbourhood]
          properties:
            attrs:
              type: object
              additionalProperties: true
              description: 'The attributes of the resource itself rather than of one use, as the CLI reported them: `from_catalog`, `archived`, `has_cve`, `advisories` for a component or a template. An attribute no bill reported is absent; empty for image, service, runner_tag and variable. They come from the most recent bill that named the resource, whichever project pushed it and whichever version it uses: not merged across projects and not per version. Read `has_cve` as "the latest bill reported this", not as a standing verdict about the resource.'
            consumers:
              type: array
              items: { $ref: '#/components/schemas/DependencyConsumer' }
              description: 'One page of the visible consumer edges of this resource, ordered by project path then project id, paginated by the page and per_page parameters of this operation.'
            consumer_edges_total:
              type: integer
              description: 'Every visible consumer edge of this resource across every page, one per (project, job); larger than consumer_count whenever a project uses the resource in several jobs.'
            consumers_page: { type: integer, description: 'The page of the consumers this response carries.' }
            consumers_per_page: { type: integer, description: 'The page size in force for the consumers, after the clamp to 100 and the default of 20.' }
            neighbourhood: { $ref: '#/components/schemas/DependencyNeighbourhood' }
    ProjectDependencyEntry:
      type: object
      description: 'One line of a project''s bill of materials, as getProjectDependencies groups it under its kind.'
      required: [resource_id, key, job, version, attrs, consumer_count]
      properties:
        resource_id:
          type: string
          format: uuid
          description: 'The resource this project depends on, the path parameter of getDependency.'
        key: { type: string, description: 'The resource key, as the CLI pushed it.' }
        job: { type: string, description: 'The job this use belongs to (image, service, runner_tag); empty for an include and for a variable, which are pipeline-level.' }
        version: { type: string, description: 'The version of this use: the include''s `version`, the image''s tag or digest. Empty for runner_tag and variable, and for a use the CLI reported without one.' }
        attrs:
          type: object
          additionalProperties: true
          description: 'The flags reported for this use, as pushed: `latest_version`, `up_to_date`, `from_catalog`, `nested`, `overridden`, `overridden_jobs`, `authorized`, `forbidden_tag` on a pushed edge, reported by the CLI; and `masked`, `protected`, `hidden`, `environment`, `type`, `origin` on a variable edge, read from the provider. Every boolean is three-state, so a flag the bill did not carry is absent rather than false. A variable''s value is never included.'
        consumer_count:
          type: integer
          description: 'Distinct visible projects using the same resource, this one included; limited to the caller''s visibility.'
    ProjectDependencies:
      type: object
      description: |-
        The getProjectDependencies response: one project's bill of materials grouped by resource kind. It is the same graph the dependencies list serves, read from the project side.
      required: [project_id, by_kind]
      properties:
        project_id: { type: string, format: uuid, description: 'The project this bill belongs to.' }
        run_id:
          type: string
          format: uuid
          nullable: true
          description: 'The default-branch run whose pushed bill of materials these entries come from. Null when no run of this project has ever carried one (only variable entries can then be present), or when retention has since deleted that run; the entries themselves are still served.'
        last_seen:
          type: string
          format: date-time
          nullable: true
          description: 'When these entries were last refreshed: the most recent moment either the pushed bill or the variable refresh named one of them, whichever is later. Null only when the project has no entry at all.'
        by_kind:
          type: object
          description: 'The project''s entries grouped by resource kind. Every DependencyKind value is always a key; a kind this project has no entry of carries an empty array, never a missing key. A project that has never pushed a bill of materials and has no variables answers six empty arrays.'
          additionalProperties:
            type: array
            items: { $ref: '#/components/schemas/ProjectDependencyEntry' }
    PushBOM:
      type: object
      description: |-
        The CLI's pipeline bill of materials, carried on the push. Optional and additive: `schema_version` does not change, a push without it is valid, and the platform then leaves the project's stored dependency edges untouched.

        The platform stores this document as pushed and projects it into the dependencies graph; it never parses CI configuration itself and never rewrites a key, a version or a flag.

        The section is bounded. A bound exceeded, a `bom` without a `version`, or a `version` other than 1 refuses the whole push with the ingest's `422` and its uniform `invalid push` body; the response does not name the bound. Unknown fields inside are ignored.

        Only a default-branch run's bill is projected into the graph; a run on another branch is stored but never touches the graph.
      required: [version]
      properties:
        version:
          type: integer
          minimum: 1
          description: 'The bom section''s own schema version, independent of the push''s `schema_version`. Required; 1 is the only accepted value. An absent version, or any other value, refuses the whole push.'
        includes:
          type: array
          description: 'The pipeline''s includes, as the CLI reports them. At most 500.'
          items: { $ref: '#/components/schemas/PushBOMInclude' }
        images:
          type: array
          description: 'The container images the pipeline''s jobs run in. At most 500.'
          items: { $ref: '#/components/schemas/PushBOMImage' }
        jobs:
          type: array
          description: 'The jobs that carry services or runner tags. At most 500.'
          items: { $ref: '#/components/schemas/PushBOMJob' }
    PushBOMInclude:
      type: object
      description: 'One include of the pipeline, as the CLI reports it. A `component` include becomes a component resource keyed `<project>/<component_name>`; a `project`, `remote` or `template` include becomes a template resource; a `local` include is never a resource (it is the project''s own file). A GitHub include type (`action`, `reusableWorkflow`) is stored if pushed but not surfaced.'
      properties:
        type: { type: string, description: 'The include type as the CLI names it: component, project, local, remote, template on GitLab; action, reusableWorkflow on GitHub.' }
        location: { type: string, description: 'The included file''s path within its source project, for the include types that have one.' }
        project: { type: string, description: 'The source project path the include reaches into.' }
        version: { type: string, description: 'The ref the include pins, exactly as written: a tag, a branch or a sha. Stored as reported, never normalised to a catalog version.' }
        latest_version: { type: string, description: 'The newest version the CLI found for this include, stored as reported.' }
        up_to_date: { type: boolean, description: 'The CLI''s verdict on whether this include is current. Absent when the CLI reached no verdict; an absent flag counts in neither direction.' }
        component_name: { type: string, description: 'The component''s own name within its source project, the second half of a component resource''s key.' }
        from_catalog: { type: boolean, nullable: true, description: 'Whether the CLI resolved this include through the instance component catalog. Three-state like every other flag on this schema: true, false, or absent when the CLI reported none; an absent flag is never defaulted to false.' }
        nested: { type: boolean, nullable: true, description: 'Whether this include was reached through another include rather than from the project''s own CI file. Three-state: true, false, or absent when the CLI reported none; an absent flag is never defaulted.' }
        overridden: { type: boolean, nullable: true, description: 'Whether the project overrides part of what this include defines. Three-state: true, false, or absent when the CLI reported none; an absent flag is never defaulted.' }
        overridden_jobs:
          type: array
          description: 'Which jobs the project overrides, and which keys of each. Present when `overridden` is true.'
          items: { $ref: '#/components/schemas/PushBOMOverriddenJob' }
        archived: { type: boolean, description: 'Whether the source project is archived, as the CLI found it.' }
        has_cve: { type: boolean, description: 'Whether the CLI found a published advisory against this include; the CLI''s own finding.' }
        advisories:
          type: array
          items: { type: string }
          description: 'The advisory identifiers the CLI reported. At most 50.'
    PushBOMOverriddenJob:
      type: object
      description: 'One job an include defines that the project overrides, with the keys overridden.'
      properties:
        job: { type: string, description: 'The job name.' }
        keys:
          type: array
          items: { type: string }
          description: 'The overridden keys of that job, as the CLI names them.'
    PushBOMImage:
      type: object
      description: 'One container image the pipeline''s jobs run in, as the CLI reports it. It becomes an image resource keyed `<registry>/<name>`, with one usage edge per job listed; the tag or digest is carried on the edge.'
      properties:
        image: { type: string, description: 'The image reference exactly as written in the CI file.' }
        registry: { type: string, description: 'The registry half of the resolved reference, the first half of the resource key.' }
        name: { type: string, description: 'The repository half of the resolved reference, the second half of the resource key.' }
        tag: { type: string, description: 'The tag, when the reference carries one.' }
        digest: { type: string, description: 'The digest, when the reference carries one. Used as the edge version when there is no tag.' }
        jobs:
          type: array
          items: { type: string }
          description: 'The jobs that run in this image. One usage edge per entry.'
        authorized: { type: boolean, description: 'The CLI''s verdict on whether this image is allowed by the policy in force. Absent when it reached none.' }
        forbidden_tag: { type: boolean, description: 'The CLI''s verdict on whether the tag is a forbidden one. Absent when it reached none.' }
    PushBOMJob:
      type: object
      description: 'One job of the pipeline, carrying the two facts that belong to jobs rather than to the pipeline: the images attached as services, and the runner tags the job selects on.'
      properties:
        name: { type: string, description: 'The job name, the `job` of every usage edge this entry produces.' }
        services:
          type: array
          description: 'The images attached to this job as services. At most 64 per job; each becomes a service resource edge.'
          items: { $ref: '#/components/schemas/PushBOMImageRef' }
        runner_tags:
          type: array
          items: { type: string }
          description: 'The job''s own `tags:` entries. At most 32 per job; each becomes a runner_tag resource edge with no version.'
    PushBOMImageRef:
      type: object
      description: 'An image reference as the CLI resolved it, used for a job''s services. The same four-part shape PushBOMImage carries, without the per-image job list and verdicts.'
      properties:
        image: { type: string, description: 'The reference exactly as written in the CI file.' }
        registry: { type: string, description: 'The registry half of the resolved reference.' }
        name: { type: string, description: 'The repository half of the resolved reference.' }
        tag: { type: string, description: 'The tag, when the reference carries one.' }
        digest: { type: string, description: 'The digest, when the reference carries one.' }

