openapi: 3.1.0
info:
  title: zkao Project API
  version: "1.0.0"
  description: |
    Project-scoped REST API for programmatically controlling a single zkao
    project with a bearer **API token** (created under Project Settings →
    Integrations, or approved through the CLI's `zkao login`).

    ## Authentication
    Send the token in the `Authorization` header: `Authorization: Bearer zkao_proj_<keyId>_<secret>`.
    The token is shown once at creation and never again. Passing the token in the
    query string is rejected.

    ## Scoping (what a token can do)
    Every token belongs to exactly one project and carries a subset of these
    **scopes**:
      - `read` — list/show repositories, scans, findings, presets
      - `findings:write` — comment, change severity, change resolution, pin a note
      - `guidance:write` — read and update a repository's guidance, and manage its audit areas
      - `scans:launch` — start a scan
      - `publish` — publish a finding or a scan

    `GET /token` tells a token which project, and which organization, it
    belongs to.

    A token may also be restricted to a subset of the project's repositories
    (a repo allowlist). Any resource outside the token's project or repo
    allowlist responds `404` (never confirmed to exist). Launching a scan
    requires the project's organization to have prepaid credits.

    ## Organizations
    Every project belongs to an **organization**, which holds the prepaid credit
    balance shared by all of its projects. Tokens stay project-scoped: the
    billing endpoints report the organization's balance as seen from this
    project, and usage attributed to this project. Projects and repositories
    also carry a `slug`, the readable segment used in app URLs
    (`/orgs/<org>/projects/<project>/repos/<repo>`).

    ## Errors
    All errors share the envelope `{ "error": { "code": "...", "message": "..." } }`.
    Identity failures (bad/expired/revoked token) collapse to a generic `401`.

    ## Polling and rate limits
    When polling a scan, honor the advisory `Retry-After` header on the scan
    response rather than polling in a tight loop. A token that sends too many
    requests receives `429` with a `Retry-After` header; back off until then.
    The official SDK's `waitForScan` does this for you.
servers:
  - url: https://zkao.io/api/v1
    description: Production
  - url: https://staging.zkao.io/api/v1
    description: Staging
security:
  - bearerAuth: []
tags:
  - name: Token
    description: The calling token and the project it belongs to
  - name: Repositories
    description: Project repositories
  - name: Scans
    description: Launch, list, and poll scans
  - name: Findings
    description: Read and triage findings
  - name: Publishing
    description: Publish findings and scans as public artifacts
  - name: Discovery
    description: Scan presets for launching
  - name: Billing
    description: The organization's prepaid credit balance, and this project's usage

paths:
  /token:
    get:
      tags: [Token]
      operationId: getTokenInfo
      summary: The calling token and its project
      description: "Any valid token may call this, whatever its scopes. Use it to learn which project (and organization) a token belongs to."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /projects/{projectId}/repositories:
    get:
      tags: [Repositories]
      operationId: listRepositories
      summary: List repositories in the project
      description: "Requires scope: `read`. Limited to the token's repo allowlist when set."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [repositories]
                properties:
                  repositories:
                    type: array
                    items: { $ref: "#/components/schemas/Repository" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/repositories/{repositoryId}/guidance:
    get:
      tags: [Repositories]
      operationId: getRepositoryGuidance
      summary: Get a repository's guidance
      description: >-
        Requires scope: `read`. Returns the per-repo guidance configured for
        this repository. Guidance is layered on top of any `zkao.md` committed
        in the repo at scan time; a per-scan `guidance` on launch replaces this
        layer for that one scan only.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/RepositoryId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RepositoryGuidance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Repositories]
      operationId: setRepositoryGuidance
      summary: Set or clear a repository's guidance
      description: >-
        Requires scope: `guidance:write`. Sets `content` as the repository's
        guidance (or clears it with `null`), recording a revision. Writing the
        same content that is already stored is a no-op (`unchanged: true`, no
        revision). Pass `expectedContent` for an optimistic compare-and-set:
        omit it for last-writer-wins, or send the content you last read (or
        `null` for "currently cleared") to get a `409` instead of clobbering a
        concurrent change.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/RepositoryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetGuidanceRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetGuidanceResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/repositories/{repositoryId}/audit-areas:
    get:
      tags: [Repositories]
      operationId: listAuditAreas
      summary: List a repository's audit areas
      description: >-
        Requires scope: `read`. Audit areas are the named subsystems a scan can
        be scoped to (`auditAreaKeys` on launch). Each scan that maps the
        repository keeps the list current; sizes come from one branch's map, so
        an area that map no longer names is returned without one.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/RepositoryId"
        - name: branch
          in: query
          required: false
          schema: { type: string }
          description: Branch whose map the sizes come from. Defaults to the repository's default branch.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditAreaList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Repositories]
      operationId: createAuditArea
      summary: Add a custom audit area
      description: >-
        Requires scope: `guidance:write`. An area is a name and a description,
        never a file list: a scan resolves it against the code at the commit it
        runs on. The `key` is derived from the name and disambiguated on
        collision.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/RepositoryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAuditAreaRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [area]
                properties:
                  area: { $ref: "#/components/schemas/AuditArea" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/repositories/{repositoryId}/audit-areas/{areaKey}:
    delete:
      tags: [Repositories]
      operationId: deleteAuditArea
      summary: Delete a custom audit area
      description: >-
        Requires scope: `guidance:write`. Only an area with `source: custom` can
        be deleted; a discovered one is the map's own account of the repository
        and is refused with `409`. Scans that already ran keep the scope they ran
        with.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/RepositoryId"
        - name: areaKey
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteAuditAreaResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/scans:
    get:
      tags: [Scans]
      operationId: listScans
      summary: List scans (most recent first)
      description: "Requires scope: `read`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedScans" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Scans]
      operationId: launchScan
      summary: Launch a scan
      description: |
        Requires scope: `scans:launch`. Reserves `creditBudget` credits from the
        project balance. The repo must be in the token's allowlist (when set) and
        ACTIVE. Discover valid `presetRef` values via `/scan-presets`. Each scan
        type runs a fixed set of flows. Per-token spend limits are enforced, and
        the project must have enough prepaid credits to cover the budget
        (otherwise `402`).

        A repository added moments ago may still be analyzing, in which case
        this returns `409 repository_initializing`. Wait for its `readiness` to
        be `ready` in `GET /repositories` and launch again.

        A diff scan preset audits only the change from `baseCommit` to the
        scanned commit, and requires `baseCommit`. Every other preset refuses
        it. A malformed, unknown, or misplaced base is a `400` with code
        `diff_base_required`, `diff_base_not_allowed`, or `diff_base_invalid`.
        A diff scan also needs a change to audit (otherwise `422`).
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LaunchScanRequest" }
      responses:
        "202":
          description: Scan accepted (queued)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchScanResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/RepositoryInitializing" }
        "422": { $ref: "#/components/responses/DiffUnavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /projects/{projectId}/scans/{scanId}:
    get:
      tags: [Scans]
      operationId: getScan
      summary: Get a single scan's status and detail
      description: >-
        Requires scope: `read`. Use for polling a launched scan. While the scan
        is not yet terminal the response carries an advisory `Retry-After`
        header telling you how long to wait before polling again; respect it
        (and back off on `429`) instead of polling in a tight loop.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ScanId"
      responses:
        "200":
          description: OK
          headers:
            Retry-After:
              description: >-
                Advisory seconds to wait before polling again. Present only
                while the scan is still running (not for terminal scans).
              required: false
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: object
                required: [scan]
                properties:
                  scan: { $ref: "#/components/schemas/ScanDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /projects/{projectId}/scans/{scanId}/cancel:
    post:
      tags: [Scans]
      operationId: cancelScan
      summary: Cancel a running or queued scan
      description: >-
        Requires scope: `scans:launch`. Signals any in-flight jobs to stop,
        marks the scan CANCELLED, and releases its reserved credits (in-flight
        jobs settle their actual spend as they wind down). A scan that already
        reached COMPLETED or FAILED cannot be cancelled (`400`).
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ScanId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CancelScanResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /projects/{projectId}/findings:
    get:
      tags: [Findings]
      operationId: listFindings
      summary: List findings
      description: "Requires scope: `read`. Optionally filter by `scanId`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: scanId
          in: query
          required: false
          schema: { type: string }
          description: Only findings belonging to this scan.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedFindings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/findings/{findingId}:
    get:
      tags: [Findings]
      operationId: getFinding
      summary: Get a single finding (full detail)
      description: "Requires scope: `read`. Includes description, PoC, recommended fix, and notes."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [finding]
                properties:
                  finding: { $ref: "#/components/schemas/FindingDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/findings/{findingId}/notes:
    post:
      tags: [Findings]
      operationId: addFindingNote
      summary: Add a comment (note) to a finding
      description: "Requires scope: `findings:write`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  minLength: 1
                  description: The comment body (markdown).
      responses:
        "201":
          description: Note created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FindingNoteResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/findings/{findingId}/notes/{noteId}/pin:
    post:
      tags: [Findings]
      operationId: pinFindingNote
      summary: Pin a note as the resolution note
      description: "Requires scope: `findings:write`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
        - name: noteId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind:
                  type: string
                  enum: [resolution]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FindingPinResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/findings/{findingId}/severity:
    patch:
      tags: [Findings]
      operationId: updateFindingSeverity
      summary: Override (or clear) a finding's severity
      description: "Requires scope: `findings:write`. Set `severity` to null to clear the override."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [severity]
              properties:
                severity:
                  oneOf:
                    - { $ref: "#/components/schemas/Severity" }
                    - { type: "null" }
                  description: New severity override, or null to clear.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FindingSeverityResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/findings/{findingId}/resolution:
    patch:
      tags: [Findings]
      operationId: updateFindingResolution
      summary: Change a finding's resolution status
      description: "Requires scope: `findings:write`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolutionStatus]
              properties:
                resolutionStatus: { $ref: "#/components/schemas/ResolutionStatus" }
                note: { $ref: "#/components/schemas/ChangeNote" }
                reason: { $ref: "#/components/schemas/ResolutionReason" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FindingResolutionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/findings/{findingId}/publish:
    post:
      tags: [Publishing]
      operationId: publishFinding
      summary: Publish a finding as a public artifact
      description: "Requires scope: `publish`. Pin a note as the published note via `noteId`."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/FindingId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublishFindingRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublishArtifactResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /projects/{projectId}/scans/{scanId}/publish:
    post:
      tags: [Publishing]
      operationId: publishScan
      summary: Publish a scan as a public artifact
      description: "Requires scope: `publish`. The scan must be COMPLETED."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ScanId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublishScanRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublishArtifactResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/scan-presets:
    get:
      tags: [Discovery]
      operationId: listScanPresets
      summary: List the scan presets (scan types) the project can launch
      description: "Requires scope: `read`. Use a preset's `ref` as `presetRef` when launching."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [presets]
                properties:
                  presets:
                    type: array
                    items: { $ref: "#/components/schemas/ScanPreset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/billing/balance:
    get:
      tags: [Billing]
      operationId: getBillingBalance
      summary: Credits available to this project
      description: "Requires scope: `read`. The organization's shared prepaid balance, available to this project and to the organization's other projects. All amounts are in credits."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingBalance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/billing/usage:
    get:
      tags: [Billing]
      operationId: getBillingUsage
      summary: Credit usage events attributed to this project
      description: "Requires scope: `read`. Signed credit delta per ledger event attributed to this project; organization-wide movements such as a top-up made outside any project are not included. Defaults to the last 30 days; newest first; max 1000 rows."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingUsage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{projectId}/billing/summary:
    get:
      tags: [Billing]
      operationId: getBillingSummary
      summary: Monthly spend and purchase summary for this project
      description: "Requires scope: `read`. Per-month net scan spend and net purchases attributed to this project, in credits, newest first. Purchases land in the organization's shared balance."
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: months
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 24 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Project API token: `zkao_proj_<keyId>_<secret>`."

  parameters:
    ProjectId:
      name: projectId
      in: path
      required: true
      schema: { type: string }
    ScanId:
      name: scanId
      in: path
      required: true
      schema: { type: string }
    FindingId:
      name: findingId
      in: path
      required: true
      description: "The finding id, or the `ZK-` label shown on the finding page (the id's last eight characters, prefix optional). A label that matches more than one finding in the project is refused with `409 conflict`; use the full id."
      schema: { type: string }
    RepositoryId:
      name: repositoryId
      in: path
      required: true
      schema: { type: string }
    Page:
      name: page
      in: query
      required: false
      schema: { type: integer, minimum: 1, default: 1 }
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, malformed, expired, or revoked token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InsufficientCredits:
      description: >-
        The project does not have enough prepaid credits to cover the scan
        budget. Top up credits and retry. (code `insufficient_credits`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RepositoryInitializing:
      description: >-
        The repository is still being analyzed and cannot be scanned yet. The
        same request succeeds once `GET /repositories` reports its `readiness`
        as `ready`. (code `repository_initializing`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    DiffUnavailable:
      description: >-
        The diff scan has nothing to audit. Code `diff_empty`: the base and the
        scanned commit have no change between them.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The token lacks the required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not in this token's project or repo allowlist
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: >-
        A compare-and-set (`expectedContent`) missed: the guidance changed
        since it was read. Re-read the current guidance and retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: >-
        Rate limited. Either the token's spend limit for the current period is
        reached (on launch), or the token is sending too many requests. Wait the
        number of seconds in the `Retry-After` header before retrying.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Organization:
      type: object
      description: The organization a project belongs to. Credits are held and billed at this level and shared by every project in it.
      required: [id, slug, name]
      properties:
        id: { type: string }
        slug: { type: string, description: "URL segment of the organization (`/orgs/<slug>`), unique across zkao." }
        name: { type: string }
    TokenInfo:
      type: object
      required: [id, name, scopes, repositoryIds, expiresAt, spendLimitCredits, project]
      properties:
        id: { type: string }
        name: { type: string }
        scopes:
          type: array
          items: { type: string }
        repositoryIds:
          type: array
          items: { type: string }
          description: Repositories the token is restricted to. Empty means every repository of the project.
        expiresAt: { type: [string, "null"], format: date-time }
        spendLimitCredits: { type: [integer, "null"], description: "Credits this token may spend. Null means no limit." }
        project:
          type: object
          required: [id, slug, name, organization]
          properties:
            id: { type: string }
            slug: { type: string }
            name: { type: string }
            organization: { $ref: "#/components/schemas/Organization" }
    BillingBalance:
      type: object
      required: [organization, balanceCredits, availableCredits, reservedCredits]
      properties:
        organization:
          $ref: "#/components/schemas/Organization"
          description: The organization whose shared balance this is.
        balanceCredits: { type: integer, description: "The organization's total prepaid credit balance, shared by all of its projects." }
        availableCredits: { type: integer, description: "Balance minus credits reserved by the organization's in-flight scans." }
        reservedCredits: { type: integer, description: "Credits held by the organization's queued/running scans." }
    UsageEventType:
      type: string
      enum: [SCAN_DEDUCTION, SCAN_REFUND, PURCHASE, STRIPE_REFUND, ADMIN_ADJUSTMENT]
    UsageEvent:
      type: object
      required: [id, at, type, credits, scanId, description]
      properties:
        id: { type: string }
        at: { type: string, format: date-time }
        type: { $ref: "#/components/schemas/UsageEventType" }
        credits: { type: integer, description: "Signed credit delta: negative = spent/clawed back, positive = added/refunded." }
        scanId: { type: [string, "null"] }
        description: { type: string }
    BillingUsage:
      type: object
      required: [from, to, events]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        events:
          type: array
          items: { $ref: "#/components/schemas/UsageEvent" }
    UsageMonthSummary:
      type: object
      required: [month, spentCredits, purchasedCredits]
      properties:
        month: { type: string, description: "YYYY-MM (UTC)." }
        spentCredits: { type: integer, description: "Net scan spend (deductions less refunds), in credits." }
        purchasedCredits: { type: integer, description: "Net purchases (purchases less clawbacks), in credits." }
    BillingSummary:
      type: object
      required: [organization, months]
      properties:
        organization:
          $ref: "#/components/schemas/Organization"
          description: The organization whose shared balance the purchases were made into.
        months:
          type: array
          items: { $ref: "#/components/schemas/UsageMonthSummary" }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [unauthorized, forbidden, not_found, bad_request, conflict, insufficient_credits, repository_initializing, diff_base_required, diff_base_not_allowed, diff_base_invalid, diff_empty, rate_limited, internal]
            message: { type: string }

    Severity:
      type: string
      enum: [CRITICAL, HIGH, MEDIUM, LOW, INFO]
    ResolutionStatus:
      type: string
      enum: [NOT_STARTED, IN_PROGRESS, RESOLVED, WONT_FIX, MITIGATED, FALSE_POSITIVE, DUPLICATE]
    TriageStatus:
      type: string
      enum: [PENDING, NEW, DUPLICATE, RECURRING, CONFIRMED, FALSE_POSITIVE, INCONCLUSIVE]
    ScanStatus:
      type: string
      enum: [QUEUED, PROCESSING, COMPLETED, FAILED, CANCELLED]

    Repository:
      type: object
      required: [id, slug, owner, name, url, status, readiness]
      properties:
        id: { type: string }
        slug: { type: string, description: "URL segment of the repository inside its project (`/orgs/<org>/projects/<project>/repos/<slug>`). Unique per project." }
        owner: { type: string }
        name: { type: string }
        url: { type: string }
        defaultBranch: { type: [string, "null"] }
        status: { type: string, description: "ACTIVE | MARKED_FOR_DELETION" }
        readiness:
          type: string
          enum: [ready, analyzing]
          description: >
            Whether a scan can start on this repository. A newly added
            repository is `analyzing` until a one-time pass over it finishes;
            launching a scan meanwhile fails with `repository_initializing`.
            Poll this endpoint rather than retrying the launch blind.

    Scan:
      type: object
      required: [id, status, repositoryId, createdAt]
      properties:
        id: { type: string }
        status: { $ref: "#/components/schemas/ScanStatus" }
        repositoryId: { type: string }
        commitHash: { type: [string, "null"] }
        baseCommit:
          type: [string, "null"]
          description: A diff scan's base, as the merge base SHA its change is measured from. Null for other scans.
        commitMessage: { type: [string, "null"] }
        presetName: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        startedAt: { type: [string, "null"], format: date-time }
        completedAt: { type: [string, "null"], format: date-time }
        progress:
          oneOf:
            - $ref: "#/components/schemas/ScanProgress"
            - type: "null"
          description: >
            Phase progress while the scan is in flight. Null once the scan
            reaches a terminal state, and null before it has been dispatched
            (no phases exist yet).

    ScanProgress:
      type: object
      required: [phasesCompleted, phasesTotal, percent]
      description: >
        How far a running scan has got through its phases. Deliberately not a
        time estimate: a phase's duration moves with the guidance it was given,
        the repository, and the model that ran it.
      properties:
        phasesCompleted:
          type: integer
          description: Phases that reached a terminal state (completed, failed, or skipped).
        phasesTotal:
          type: integer
          description: Phases this scan will run, fixed when it was dispatched.
        percent:
          type: integer
          minimum: 0
          maximum: 100
          description: >
            Weighted completion. Each phase counts for its share of the scan
            budget, so this does not simply equal phasesCompleted / phasesTotal.

    ScanDetail:
      allOf:
        - $ref: "#/components/schemas/Scan"
        - type: object
          properties:
            branch: { type: [string, "null"] }
            creditBudget:
              type: [integer, "null"]
              description: Reserved budget for the scan, in credits.
            findingsSummary:
              type: [object, "null"]
              properties:
                total: { type: integer }
                bySeverity:
                  type: object
                  additionalProperties: { type: integer }

    Finding:
      type: object
      required: [id, displayId, title, location, severity, category, triageStatus, resolutionStatus, notesCount]
      properties:
        id: { type: string }
        displayId: { type: integer }
        title: { type: string }
        location: { type: string }
        severity:
          $ref: "#/components/schemas/Severity"
          description: Effective severity (user override if set, else AI severity).
        category: { type: string }
        triageStatus: { $ref: "#/components/schemas/TriageStatus" }
        confirmationEvidence:
          type: [string, "null"]
          enum: [POC, ANALYSIS, null]
          description: What backs a CONFIRMED verdict. POC means a proof of concept ran and demonstrated the issue. ANALYSIS means it was confirmed by code analysis alone. Null for other statuses or when not recorded.
        resolutionStatus: { $ref: "#/components/schemas/ResolutionStatus" }
        notesCount: { type: integer }

    FindingNote:
      type: object
      required: [id, body, createdAt]
      properties:
        id: { type: string }
        body: { type: string }
        createdAt: { type: string, format: date-time }
        author:
          type: [object, "null"]
          properties:
            id: { type: string }
            name: { type: [string, "null"] }
            email: { type: string }

    FindingDetail:
      allOf:
        - $ref: "#/components/schemas/Finding"
        - type: object
          required: [description, scanId, createdAt, notes]
          properties:
            description: { type: string }
            pocReport: { type: [string, "null"] }
            recommendedFix: { type: [string, "null"] }
            scanId: { type: string }
            commitHash: { type: [string, "null"] }
            repo:
              type: [object, "null"]
              properties:
                owner: { type: string }
                name: { type: string }
                url: { type: string }
            createdAt: { type: string, format: date-time }
            notes:
              type: array
              items: { $ref: "#/components/schemas/FindingNote" }

    PaginatedScans:
      type: object
      required: [items, page, limit, total]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Scan" }
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }

    PaginatedFindings:
      type: object
      required: [items, page, limit, total]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Finding" }
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }

    ScanPreset:
      type: object
      required: [ref, name, minCredits, modelTier]
      properties:
        ref: { type: string, description: "Pass as `presetRef` when launching." }
        name: { type: string }
        description: { type: [string, "null"] }
        minCredits:
          type: integer
          description: "Minimum budget for a launch. 0 = use the default floor."
        modelTier: { type: string }

    LaunchScanRequest:
      type: object
      required: [repositoryId]
      properties:
        repositoryId: { type: string }
        creditBudget:
          type: integer
          minimum: 1
          description: >-
            Max budget for the scan, in credits. Reserved at launch. Omit it to
            launch at the budget zkao recommends for this scan type and scope
            on this repository: sized from what past scans of it spent, half
            again the last budget when that scan ran short of it, or the scan
            type's minimum on the first.
        presetRef:
          type: string
          description: A preset ref from /scan-presets. Defaults to the first active preset.
        branch:
          type: [string, "null"]
          description: Branch to scan (resolved to its head commit server-side). Ignored if commitHash is set.
        commitHash:
          type: [string, "null"]
          description: Pin a specific commit SHA (7-40 hex chars).
        commitMessage: { type: [string, "null"] }
        baseCommit:
          type: [string, "null"]
          description: >-
            What a diff scan's change is measured from: a commit SHA, branch, or
            tag. Required by a diff scan preset and refused by every other. The
            scan records the merge base of this and the scanned commit.
        auditAreaKeys:
          type: array
          items: { type: string }
          description: >-
            Area keys from /audit-areas to scope this scan to. Omit or send an
            empty array to scan the whole repository. A key the repository's
            current map no longer names fails the launch rather than being
            dropped, so a scan budgeted for one subsystem never silently runs
            against everything.
        guidance:
          type: [string, "null"]
          maxLength: 100000
          description: >-
            Guidance for this scan only, replacing the repository's configured
            guidance layer (it is still layered over any committed zkao.md).
            Omit the field to inherit the repository's guidance; send null to
            scan with no guidance layer.

    LaunchScanResult:
      type: object
      required: [scanId, queued, creditBudget]
      properties:
        scanId: { type: string }
        queued: { type: boolean }
        creditBudget:
          type: integer
          description: The budget the scan was launched with, in credits.

    RepositoryGuidance:
      type: object
      required: [repositoryId, content, updatedAt]
      properties:
        repositoryId: { type: string }
        content:
          type: [string, "null"]
          description: The configured guidance, or null when none is set.
        updatedAt:
          type: [string, "null"]
          format: date-time
          description: >-
            When the guidance last changed. Null when the repo has no revision
            history (guidance never set, or set before revisions were tracked).

    SetGuidanceRequest:
      type: object
      required: [content]
      properties:
        content:
          type: [string, "null"]
          maxLength: 100000
          description: New guidance content; null clears it.
        expectedContent:
          type: [string, "null"]
          description: >-
            Optional compare-and-set. Omit for last-writer-wins. Send the
            content you last read (or null for "currently cleared") to receive a
            409 if the guidance changed underneath you instead of overwriting it.

    SetGuidanceResult:
      type: object
      required: [repositoryId, revisionId, unchanged]
      properties:
        repositoryId: { type: string }
        revisionId:
          type: [string, "null"]
          description: The recorded revision id, or null on a no-op write.
        unchanged:
          type: boolean
          description: True when the content already matched (no revision recorded).

    AuditArea:
      type: object
      required: [key, name, description, source, inLatestMap, files, lines]
      properties:
        key:
          type: string
          description: Stable slug, unique per repository. Pass it in `auditAreaKeys` when launching a scan.
        name: { type: string }
        description: { type: [string, "null"] }
        source:
          type: string
          enum: [discovered, custom]
          description: >-
            `discovered` was named by a scan's map of the repository; `custom`
            was added through this API or the app.
        inLatestMap:
          type: boolean
          description: Whether the branch's latest map still names this area.
        files:
          type: [integer, "null"]
          description: Files this area covers at that map. Null when the map does not name it.
        lines:
          type: [integer, "null"]
          description: Lines this area covers at that map. Null when the map does not name it.

    AuditAreaList:
      type: object
      required: [repositoryId, branch, mapped, totalFiles, totalLines, areas]
      properties:
        repositoryId: { type: string }
        branch:
          type: [string, "null"]
          description: Branch the sizes were read from.
        mapped:
          type: boolean
          description: Whether that branch has a stored map at all.
        totalFiles: { type: [integer, "null"] }
        totalLines: { type: [integer, "null"] }
        areas:
          type: array
          description: The map's own audit order first, then the areas it no longer names.
          items: { $ref: "#/components/schemas/AuditArea" }

    CreateAuditAreaRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 120
        description:
          type: [string, "null"]
          maxLength: 500
          description: What this part of the code does.

    DeleteAuditAreaResult:
      type: object
      required: [repositoryId, key, deleted]
      properties:
        repositoryId: { type: string }
        key: { type: string }
        deleted: { type: boolean }

    CancelScanResult:
      type: object
      required: [scanId, status, cancelledJobs]
      properties:
        scanId: { type: string }
        status:
          $ref: "#/components/schemas/ScanStatus"
          description: Terminal status after cancellation (always CANCELLED).
        cancelledJobs:
          type: integer
          description: Number of in-flight jobs signalled to stop.

    ChangeNote:
      type: object
      description: Optionally attach or reuse a note alongside a resolution change.
      properties:
        content: { type: string, description: Create a new note with this body. }
        existingNoteId: { type: string, description: Reuse an existing note by id. }

    FindingNoteResult:
      type: object
      required: [noteId, findingId]
      properties:
        noteId: { type: string }
        findingId: { type: string }

    FindingPinResult:
      type: object
      required: [findingId, noteId]
      properties:
        findingId: { type: string }
        noteId: { type: [string, "null"] }

    FindingSeverityResult:
      type: object
      required: [findingId, effectiveSeverity]
      properties:
        findingId: { type: string }
        userSeverity:
          oneOf:
            - { $ref: "#/components/schemas/Severity" }
            - { type: "null" }
        effectiveSeverity: { $ref: "#/components/schemas/Severity" }

    FindingResolutionResult:
      type: object
      required: [findingId, resolutionStatus]
      properties:
        findingId: { type: string }
        resolutionStatus: { $ref: "#/components/schemas/ResolutionStatus" }

    ResolutionReason:
      type: string
      description: >
        Short code for why a finding is being closed, chosen from the set
        offered for the `resolutionStatus` it is sent with; a code belonging to
        a different status is rejected with 400. Ignored for the open statuses
        (`NOT_STARTED`, `IN_PROGRESS`) and when `note` is also given. It is not
        stored as a field: it is recorded as a comment on the finding stating
        what was chosen, which is returned by the notes endpoints.


        Codes per status.
        `RESOLVED`: fixed_in_code, fixed_upstream, code_removed.
        `MITIGATED`: compensating_control, limited_exposure, monitored.
        `FALSE_POSITIVE`: not_reachable, guarded_elsewhere, intended_behavior,
        misread_code, bad_assumption.
        `WONT_FIX`: risk_accepted, out_of_scope, not_worth_fixing,
        code_being_removed.
        `DUPLICATE`: duplicate_of_finding, same_root_cause.
      enum:
        - fixed_in_code
        - fixed_upstream
        - code_removed
        - compensating_control
        - limited_exposure
        - monitored
        - not_reachable
        - guarded_elsewhere
        - intended_behavior
        - misread_code
        - bad_assumption
        - risk_accepted
        - out_of_scope
        - not_worth_fixing
        - code_being_removed
        - duplicate_of_finding
        - same_root_cause
      example: not_reachable

    PublishFindingRequest:
      type: object
      properties:
        noteId:
          type: string
          description: A note id on the finding to pin as the published note.
        withPassword:
          type: boolean
          description: Generate a password to gate access to the public artifact.

    PublishScanRequest:
      type: object
      properties:
        withPassword:
          type: boolean
          description: Generate a password to gate access to the public artifact.

    PublishArtifactResult:
      type: object
      required: [artifactId, publicId]
      properties:
        artifactId: { type: string }
        publicId: { type: string, description: "Slug in /public/{scans,findings}/<publicId>." }
        accessPassword:
          type: [string, "null"]
          description: The generated access password when withPassword was requested; otherwise null.
