openapi: 3.0.3
info:
  title: Indexify API
  description: >
    HTTP API for **projects**, **knowledge bases (KBs)**, **documents**, **asynchronous jobs**, **semantic and hybrid search**,
    **webhooks**, and **structured document access** (elements, sections, tables, figures, relationships). This document
    is the supported contract for HTTP integrations and for **developers** building retrieval, citations, and agent tools on hosted knowledge bases.

    **Typical integration**
    1. Create a developer account and sign in to obtain a **developer JWT** (human-facing session).
    2. Create a **project**, then create **project credentials** (`client_id` / `client_secret`).
    3. Call **`POST /oauth/token`** with `grant_type=client_credentials` and **`client_id`** / **`client_secret`** in the **form body** (`application/x-www-form-urlencoded`) to obtain a **project access token** (Bearer). Cache it until shortly before expiry, then request a new token.

    **Authentication (do not mix tokens)**
    - **Developer JWT** — Use for signup, login, password reset, **project** and **credential** management, and **`GET /health`**. Do **not** send it to KB, document, job, search, or webhook endpoints.
    - **Project access token** — Required for machine-to-machine access to KBs, documents, jobs, search, and webhooks. Obtained only via **`POST /oauth/token`** with project credentials.

    **Tenancy**
    A **project** owns one or more knowledge bases. Every KB-scoped path includes **`{projectId}`** and **`{kbId}`**.

    **Pagination (list endpoints)**
    Most paginated **GET** operations return **`items`** and **`nextCursor`**. Unless an operation documents an exception, treat **`nextCursor`** as **opaque**: pass it back as the **`cursor`** query parameter **unchanged** for the next page. Do **not** parse, construct, or reuse cursors across different paths or query combinations.

    **Pagination exceptions** (see each operation for exact rules):
    - **`GET /projects`**, **`GET .../kbs`**, **`GET .../documents`** (document list) — **`cursor`** / **`nextCursor`** are the last row’s **`id`** (UUID) for that collection. Sorting uses the chosen field (**`createdAt`** or **`updatedAt`**) with **`id`** as a stable tie-breaker. Pass **`nextCursor`** back verbatim; wrong or stale cursors yield **`400`** (**`invalid_cursor`**).
    - **`GET .../kbs/{kbId}/jobs`** — **`cursor`** / **`nextCursor`** are the job **`id`** (UUID). Sorting is **`createdAt`** desc with **`id`** desc. Pass **`nextCursor`** back with the **same** **`status`** filter; invalid UUIDs or cursors outside the filtered set → **`400`** (**`invalid_input`** / **`invalid_cursor`**).
    - **`GET .../documents/{documentId}/artifacts`** — **`cursor`** / **`nextCursor`** are the artifact **`id`** (UUID). Pass the prior **`nextCursor`** verbatim.
    - **`GET .../jobs/{jobId}/artifacts`** — **`cursor`** / **`nextCursor`** are **opaque**; pass back verbatim.
    - **`POST .../search`** — **`nextCursor`** depends on **`settings`** (see **`SearchRequest`**).
    - **`GET .../documents/{documentId}/chunks`** — cursor format is defined on that operation.

    **Rate limiting**
    Project access tokens are rate-limited **per project**; unauthenticated requests are limited **per client IP**. A **`429 Too Many Requests`** response uses **`application/problem+json`** and includes **`Retry-After`** (seconds to wait). Successful and **`429`** responses include **`X-RateLimit-Limit`**, **`X-RateLimit-Remaining`**, and **`X-RateLimit-Reset`** (Unix timestamp). Some routes also attach the same headers to **non-429** responses **after** a successful rate-limit check (for example **`GET .../kbs/{kbId}/jobs`** and **`GET .../jobs/{jobId}`**). Use exponential backoff with jitter on repeated **`429`** responses.

    **Errors**
    Most error responses use **`application/problem+json`** (**RFC 7807** **`Problem`**) with **`title`**, **`status`**, **`detail`**, and often **`code`** from **`ProblemCode`**. Prefer **`code`** for programmatic handling when present; treat values **not** listed under **`ProblemCode`** as unknown and use **`detail`** for logs and operator messages. Every **`Problem`** includes **`title`**, **`status`**, and **`detail`**.

    **Webhook signatures**
    When a webhook **`secret`** is set, each delivery is signed—see **Webhooks** for **`X-Indexify-Signature`** (HMAC-SHA256 over the **raw** body, lowercase hex).

    **Defaults**
    Optional query parameters and JSON fields behave as documented on each parameter or schema property. If no default is stated, omitting the field does **not** guarantee a specific behavior—verify in your environment or with support before relying on it in production.

    **MCP (Model Context Protocol)**
    Agent hosts can call the same search request body through the hosted MCP integration (**Streamable HTTP**); see the product **Guide** → **Agents & MCP** for tool names and a practical retrieval workflow. MCP endpoints are **not** listed in this document—use these paths for REST.
  version: 1.6.0

tags:
  - name: Auth
    description: Developer signup, login, password reset, and OAuth2 client-credentials tokens for projects.
  - name: Projects
    description: Create and manage projects and project credentials (developer JWT required).
  - name: KnowledgeBases
    description: Create, list, update, and delete knowledge bases and their processing settings within a project.
  - name: Documents
    description: Upload and manage documents, retrieve parsed output, chunks, and structured layout data for agents.
  - name: Structure
    description: |
      per-document structure reads (elements, tables, figures, sections, relationships, artifacts). Pair with **`POST …/search`**
      when you need ranked passage retrieval rather than per-document structure reads alone.
  - name: Jobs
    description: List jobs, read job status, list job artifacts, cancel, and retry for a knowledge base.
  - name: Search
    description: |
      Semantic and hybrid **`POST …/search`** over chunks, with optional reranking and multimodal retrieval modes configured per KB.
  - name: Webhook
    description: Configure, update, remove, and test at most one outbound webhook URL per knowledge base.
  - name: Health
    description: Unauthenticated health and dependency status for monitoring and smoke tests.

servers:
  - url: https://api.indexify.dev
    description: Production

# Default security: project access token (M2M)
security:
  - projectBearerAuth: []

paths:
  # ---------------------------
  # Human auth (Developer JWT)
  # ---------------------------
  /auth/signup:
    post:
      summary: Create a developer account
      description: |
        Register a new developer account. Use this for human (developer) access only—not for
        machine-to-machine calls. After signup you can create projects and obtain project
        credentials for API access.
        
        **Password requirements:** 8–64 characters, at least one uppercase, one lowercase,
        one digit, and one special character. Store credentials securely; the API never
        returns the password.
        
        **Next steps:** Call `/auth/login` to obtain a Developer JWT, then create a project
        and credentials under `/projects` and `/projects/{projectId}/credentials`.
      operationId: authSignup
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthSignupRequest"
      responses:
        "201":
          description: Created
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Developer"
        "409":
          description: |
            The **`email`** is already registered (**`email_exists`** in **`Problem.code`**). Use **`POST /auth/login`**
            instead of creating a second account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: Invalid input
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service unavailable
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /auth/login:
    post:
      summary: Login and receive developer JWT (human session)
      description: |
        Exchange email and password for a short-lived Developer JWT. This token is for
        **human** operations only: creating and managing projects, creating and revoking
        project credentials, and calling the health endpoint.
        
        **Important:** Do not use the Developer JWT for KB, document, job, search, or webhook
        endpoints. Those require a **project access token** from **`POST /oauth/token`** with
        **`grant_type=client_credentials`** and the project's **`client_id`** / **`client_secret`** in the
        **`application/x-www-form-urlencoded`** body (HTTP Basic is not supported on that endpoint).
      operationId: authLogin
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthLoginRequest"
      responses:
        "200":
          description: Authenticated
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthLoginResponse"
        "400":
          description: |
            Malformed JSON (unparseable body) or **request validation** failure (for example invalid **`email`**
            format or shape). This is **not** used for wrong email/password pairs—those yield **`401`** so clients
            can distinguish bad input from bad credentials.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "401":
          description: |
            **Invalid credentials:** unknown email or password does not match the stored hash. Uses the same
            **`detail`** for both cases to avoid account enumeration. **`code`** is typically **`invalid_credentials`**.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service unavailable
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected server error while authenticating, or a documented failure such as **`login_failed`**
            (**`Problem.code`**). Prefer **`status`** and **`detail`**; use **`code`** when present for programmatic handling.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /auth/password-reset:
    post:
      summary: Request password reset code
      description: |
        Request a one-time password reset code sent to the developer's email. Use this when
        a user has forgotten their password. The code is time-limited; call
        `/auth/password-reset/confirm` with the code and new password to complete the reset.
        
        **Rate limiting:** Requests are rate-limited to prevent abuse. Use exponential backoff
        if you receive 429 responses.
      operationId: authPasswordResetRequest
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasswordResetRequest"
      responses:
        "200":
          description: |
            **Opaque success** for anti-enumeration: the same **`200`** and **`MessageResponse`** are returned
            when no developer account exists for the email (nothing is sent) **or** when a reset code was created
            and the delivery email was accepted by the mail path. If an account exists but outbound email cannot
            be sent, the API returns **`503`** with **`password_reset_delivery_failed`** instead of **`200`**.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "422":
          description: Invalid input
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: |
            Service temporarily unavailable (**`service_unavailable`**) or **password reset email could not be sent**
            (**`password_reset_delivery_failed`**). Retry with backoff. Note: a **`503`** after a valid-looking request
            can imply delivery was attempted (see **`200`** description); do not use it to assert an account exists.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure while processing the request (**`password_reset_request_failed`** or unexpected server error).
            Prefer **`status`** and **`detail`**; use **`code`** when present.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /auth/password-reset/confirm:
    post:
      summary: Reset password with code
      description: |
        Complete the password reset flow by submitting the code received via email and the
        new password. The code is invalid after use or once it expires. New password must
        meet the same requirements as signup (8–64 chars, mixed case, digit, special char).
        
        **After reset:** The user can log in with the new password via `/auth/login`.
      operationId: authPasswordResetConfirm
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasswordResetConfirmRequest"
      responses:
        "200":
          description: Password reset successful
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "400":
          description: |
            Invalid or expired reset code, or code already used. **`Problem.title`** is **`Invalid or expired reset code`**;
            **`code`** is typically **`invalid_reset_code`**.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: Invalid input
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service unavailable
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure while completing the reset (**`reset_failed`** or unexpected server error).
            Prefer **`status`** and **`detail`**; use **`code`** when present.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # ---------------------------
  # Machine auth (Project token)
  # ---------------------------
  /oauth/token:
    post:
      summary: Obtain a project access token (client_credentials)
      description: |
        Exchange **project credentials** for a short-lived **project access token** (Bearer). Use that token on every

        **Body (required):** `application/x-www-form-urlencoded` with **`grant_type`**, **`client_id`**, and **`client_secret`**
        (see **`OAuthTokenRequest`**). **HTTP Basic** authentication is **not** supported—send credentials **only** in the form body.

        **Scopes:** The token always carries the **full scope list** configured on the credential; there is **no** per-token scope narrowing on this endpoint.

        **Response:** **`expires_in`** is the token lifetime in **seconds** from issuance (typical deployments use **3600**;
        do not hard-code—read the field). **`scopes`** echoes those credential permissions.

        **Rate limiting:** Uses the **default per-client-IP** bucket (`RATE_LIMIT_*` / `RATE_LIMIT_ENABLED`), **not** the stricter human **`/auth/*`** limits.

        **When to call:** Refresh proactively before expiry, and always after **`401 Unauthorized`** on data-plane routes.
        Cache one active token per credential in your service; avoid issuing a new token on every HTTP call.
      operationId: oauthToken
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
      responses:
        "200":
          description: Token issued
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthTokenResponse"
              example:
                access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
                token_type: "Bearer"
                expires_in: 3600
                scopes:
                  - kb:read
                  - kb:write
                  - kb:delete
                  - docs:read
                  - docs:ingest
                  - docs:parsed:read
                  - docs:delete
                  - jobs:read
                  - jobs:cancel
                  - jobs:retry
                  - search:run
                  - webhooks:read
                  - webhooks:write
                  - webhooks:delete
                  - webhooks:test
        "400":
          description: |
            Malformed body (not `application/x-www-form-urlencoded`), missing **`grant_type`**, **`client_id`**, or **`client_secret`**,
            unsupported **`grant_type`**, **`Authorization: Basic …`** (rejected—use the form body only), or other invalid request input (**`invalid_input`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "401":
          description: |
            Invalid **`client_id`** / **`client_secret`** in the form body, or credentials rejected (**`invalid_credentials`**, **`credential_revoked`**, etc.).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: |
            Service temporarily unavailable during credential validation (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure issuing the token (**`token_issuance_failed`**, **`credential_validation_failed`**, or unexpected server error). Prefer **`status`** and **`detail`**; use **`code`** when present.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # ---------------------------
  # Project management (HUMAN JWT)
  # ---------------------------
  /projects:
    get:
      summary: List projects
      description: |
        Returns all projects for the authenticated developer. Use this to enumerate
        projects before creating credentials or managing a specific project. Results are
        paginated; use **`limit`**, **`cursor`**, **`sort`**, and **`order`** for large lists.
        Sorting applies **`createdAt`** or **`updatedAt`** with **`id`** as a tie-breaker so pages are stable.
        Requires a **Developer JWT** (human token), not a project access token.
      operationId: listProjects
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: |
            Maximum number of items to return in a single page.
            - Minimum: 1
            - Maximum: 100
            - Default: 20
            
            Use pagination with the `cursor` parameter to retrieve additional pages.
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            **`nextCursor`** from the prior response (the last **project `id`** on that page). Must belong to
            your account and match the same **`sort`** / **`order`** you used when issuing that page. Omit for the first page.
            Invalid or foreign UUIDs → **`400`** (**`invalid_cursor`**).
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [createdAt, updatedAt] }
          description: |
            Field to order by. Omit for **`createdAt`**. Only **`createdAt`** and **`updatedAt`** are allowed; any other value → **`400`**.
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: |
            Sort order for results.
            - `asc`: Ascending order (oldest first)
            - `desc`: Descending order (newest first, default)
      responses:
        "200":
          description: Projects page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedProjects"
        "400":
          description: |
            Invalid query parameters: **`limit`** out of range, malformed **`cursor`** (not a UUID), unknown **`sort`** / **`order`**, or **`invalid_cursor`** when the cursor does not match a visible project for this developer.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure listing projects (**`project_list_failed`** or unexpected server error). Prefer **`status`** and **`detail`**; use **`code`** when present.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    post:
      summary: Create a project
      description: |
        Create a new project. A project is the top-level container for knowledge bases and
        machine credentials. After creation, create at least one credential via
        `/projects/{projectId}/credentials` so that your services can call KB, document, and
        search APIs with a project access token.
        
        **Name:** Project names must be **unique within your developer account** (developer namespace).
        
        **Settings:** Optional `settings` configures monitoring notifications (e.g. ingestion failures);
        when monitoring is enabled and no email is set, the developer email is used. Omit `settings` entirely
        to use server defaults (**`monitoringEnabled: true`**, notifications to the developer email).

        **Idempotency:** Optional `Idempotency-Key` header. Matching responses may be retained for **24 hours** (TTL).
        Entries are scoped to your developer account **and** a fingerprint of the normalized JSON body
        (**`name`**, **`description`**, **`settings`** after defaults). Reusing the same key with a **different**
        body therefore does **not** replay a prior response—it behaves as a separate request.
        Successful **`201`** and conflict **`409`** (**`project_name_duplicate`**) responses may be replayed;
        transient **`503`** and **`500`** outcomes are **not** cached, so retries after those statuses are not
        masked by idempotency. If idempotency storage is unavailable, duplicate suppression may be skipped; the
        request still runs normally.
      operationId: createProject
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Optional client-supplied key (e.g. UUID). Leading/trailing **whitespace is trimmed**; a
            whitespace-only value is treated as absent.

            When set, **`201`** and **`409`** responses may be cached for **24 hours**, keyed by
            developer, this header value, and a **body fingerprint** (normalized **`name`**, **`description`**,
            **`settings`**). Same key + **different** JSON body → **separate** cache entry (no cross-body replay).

            **Replay:** Retries may receive the cached **`201`** or **`409`** body and status. **`500`** / **`503`**
            are not stored; retry after those as usual.

            **Degraded mode:** If idempotency storage is unavailable, the route proceeds without duplicate suppression.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectCreateRequest" }
            examples:
              minimal:
                summary: Minimal (use developer email for notifications)
                value:
                  name: "production-api"
              withSettings:
                summary: With custom notification settings
                value:
                  name: "production-api"
                  description: "Production API for agent-developer knowledge base"
                  settings:
                    monitoringEnabled: true
                    notificationEmail: "notifications@myapp.com"
      responses:
        "201":
          description: Project created
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            Project name already exists for this developer (**`project_name_duplicate`** in **`code`**).
            With **`Idempotency-Key`**, this response may be replayed from cache for identical body fingerprint within the TTL.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Invalid input
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: |
            Service temporarily unavailable during create (**`service_unavailable`**). Retry with backoff.
            Not cached for idempotency—safe to retry with the same **`Idempotency-Key`**.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure creating the project (**`project_creation_failed`** or unexpected server error).
            Prefer **`status`** and **`detail`**; use **`code`** when present. Not cached for idempotency.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}:
    get:
      summary: Get project
      description: |
        Fetch a single project by ID. Returns name, description, settings (monitoring notifications), and
        timestamps. Use this to verify project existence or display project details in a
        dashboard. Requires Developer JWT.

        **Visibility:** Soft-deleted projects are not returned; the API responds with **`404`**
        (**`project_not_found`**) as for a missing ID or a project owned by another developer.
      operationId: getProject
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values (not a UUID) → **`422`**. Valid UUIDs that do not
            exist, belong to another developer, or refer to a soft-deleted project → **`404`**
            (**`project_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Project
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            No visible project for this **`projectId`** and developer (**`project_not_found`**): unknown id,
            wrong account, or soft-deleted project.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Invalid **`projectId`** (not a UUID) or other validation failure on path parameters.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: |
            Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure retrieving the project (**`project_get_failed`** or unexpected server error).
            Prefer **`status`** and **`detail`**; use **`code`** when present.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    patch:
      summary: Update project
      description: |
        Partially update a project. Send only the fields you want to change; omitted **top-level**
        keys are left unchanged. Use this to rename a project, update its description, or change
        project settings (monitoring notifications). Requires Developer JWT.

        **`settings` merge:** When you include **`settings`**, only the sub-fields you send are applied;
        omitted **`monitoringEnabled`** / **`notificationEmail`** keys keep their **stored** values
        (not server defaults). Send **`notificationEmail`: null** explicitly to clear the override
        and use the developer account email when monitoring is on.
      operationId: updateProject
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. See **GET** for not-found semantics.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUpdateRequest" }
            examples:
              updateSettings:
                summary: Update notification settings
                value:
                  settings:
                    monitoringEnabled: true
                    notificationEmail: "alerts@myapp.com"
              updateNameAndDescription:
                summary: Update name and description
                value:
                  name: "production-api"
                  description: "Production API for agent-developer KB (updated)"
              updateAll:
                summary: Update name, description, and settings
                value:
                  name: "production-api"
                  description: "Production API for agent-developer knowledge base"
                  settings:
                    monitoringEnabled: true
                    notificationEmail: "notifications@myapp.com"
      responses:
        "200":
          description: Project updated
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Not found (**`project_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            Duplicate project name for this developer (**`project_name_duplicate`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Invalid **`projectId`**, invalid JSON body, body validation failure, or unknown top-level
            properties (**`additionalProperties`** not allowed; matches strict JSON schema validation).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure updating the project (**`project_update_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    delete:
      summary: Delete project (human, soft by default)
      description: |
        Delete a project. Default is **soft delete**: the project is marked deleted but data
        is retained for a retention period so it can be recovered. Use query parameter
        **`hard`** to permanently remove the project and all associated data (irreversible).
        Requires Developer JWT.

        **`hard` query:** Use **`true`** or **`1`** (case-insensitive) for hard delete; **`false`**, **`0`**, or an empty
        value for soft delete. Omit the parameter for soft delete. Invalid values → **`422`**
        (**`invalid_input`**).

        **Soft-deleted projects:** A second **soft** delete returns **`404`**. **`hard=true`** applies to the row
        whether it is active or already soft-deleted, so you can **purge** after a soft delete.
      operationId: deleteProject
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. See **GET** for not-found semantics.
          schema: { type: string, format: uuid }
        - name: hard
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: |
            Deletion mode (string query parameter; parsed case-insensitively).
            - **Omit** or **`false`** / **`0`** / empty → soft delete (sets **`deletedAt`**).
            - **`true`** / **`1`** → hard delete (permanent removal and cascades).
            Other values → **`422`** (**`invalid_input`**).
      responses:
        "204":
          description: Deleted
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Not found (**`project_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Invalid **`projectId`** (not a UUID), or invalid **`hard`** query value (**`invalid_input`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure deleting the project (**`project_delete_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/credentials:
    get:
      summary: List project credentials
      description: |
        List all credentials for the project. Returns metadata (**`id`**, **`name`**, **`scopes`**, **`clientId`**, **`createdAt`**, **`revokedAt`**)
        only—**`clientSecret` is never returned** (it is shown only on **POST** create). Rows include **both active**
        (**`revokedAt`: null**) and **revoked** credentials (non-null **`revokedAt`**), ordered by **`createdAt`** descending,
        subject to retention policy for revoked rows. Use this to audit clients or find a **`credentialId`** before **DELETE** revoke.
        Requires Developer JWT.
      operationId: listProjectCredentials
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. Missing project, another developer’s project, or soft-deleted project → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Credentials list
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    description: |
                      Active (**`revokedAt`: null**) and revoked credentials; newest **`createdAt`** first. Retention may drop very old revoked rows.
                    items: { $ref: "#/components/schemas/ProjectCredential" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Project not found, not owned by this developer, or soft-deleted (**`project_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Invalid **`projectId`** (not a UUID).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure listing credentials (**`credential_list_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    post:
      summary: Create project credentials
      description: |
        Create a new credential (client_id and client_secret) for the project. The
        **client_secret is returned only once**—store it securely; you cannot retrieve it
        later. Use these credentials with `/oauth/token` to obtain project access tokens.
        
        **Name:** Required. Must be **unique within this project** (each name is tied to one row, including revoked credentials).
        
        **Scopes:** Define which APIs this credential may call (for example **`kb:read`**, **`docs:ingest`**, **`docs:parsed:read`**
        If **`scopes`** is omitted, the **`DefaultProjectScopes`** list is applied (see schema). For production, issue
        least-privilege credentials (e.g. read-only vs ingest-only) instead of sharing one super-scoped secret everywhere.

        **Idempotency:** Optional **`Idempotency-Key`**. Cache is scoped to developer, project, key, and a **body fingerprint**
        (**`name`** + normalized **`scopes`**, including default scopes when omitted). Same key with a **different** body uses a
        separate cache entry. **201** and **409** (**`credential_name_conflict`**) may be replayed from the idempotency cache (24h TTL).
        **500** / **503** are not cached. If idempotency storage is unavailable, duplicate suppression is skipped.
      operationId: createProjectCredentials
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. Invalid or inaccessible project → **`404`**.
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Optional client-supplied key (e.g. UUID). Trimmed; whitespace-only treated as absent.

            When set, **201** and **409** responses may be cached for **24 hours**, keyed by developer, project,
            this header value, and a **body fingerprint** (**`name`** + effective **`scopes`** after defaults). Same key +
            **different** JSON → **separate** cache entry.

            **Replay:** Retries may receive cached **201** (created credential JSON including **`clientSecret`**) or **409**
            conflict body. **500** / **503** are not stored.

            **Degraded mode:** If idempotency storage is unavailable, the route runs without duplicate suppression.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/ProjectCredentialCreateRequest" }
      responses:
        "201":
          description: Credential created
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                { $ref: "#/components/schemas/ProjectCredentialWithSecret" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Project not found
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            Credential **name** already exists for this project (**`credential_name_conflict`**). May be replayed when
            **`Idempotency-Key`** matches the same body fingerprint within the TTL.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Invalid **`projectId`**, invalid JSON body, or body validation failure.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure creating the credential (**`credential_creation_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/credentials/{credentialId}:
    delete:
      summary: Revoke project credential
      description: |
        Revokes the credential by setting **`revokedAt`** (soft revoke; the row may remain for audit/retention).
        **`/oauth/token`** rejects revoked credentials (**`credential_revoked`**). Use when rotating secrets,
        decommissioning a service, or after a leak. Requires **Developer JWT**.

        **Idempotency:** Calling **DELETE** again for the same credential after it is already revoked returns **`204`**
        with an empty body (no error). Only **`404`** when the **`credentialId`** does not belong to this **`projectId`**
        (or the project is missing or not accessible to the developer).
      operationId: revokeProjectCredential
      tags: [Projects]
      security:
        - developerBearerAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. Invalid or inaccessible project for this developer → **`404`**
            (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: credentialId
          in: path
          required: true
          description: |
            Credential identifier (**UUID**). Malformed values → **`422`**. Unknown id or credential in another project → **`404`**
            (**`credential_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: |
            Credential revoked, or already revoked (**idempotent**). Empty body. **`X-RateLimit-*`** headers are included.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Project not found or not owned by the developer (**`project_not_found`**), or credential not found under this project
            (**`credential_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Malformed **`projectId`** or **`credentialId`** (not a valid UUID).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure revoking the credential (**`credential_revoke_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # --------------------------------------
  # KB management (PROJECT TOKEN ONLY)
  # Project manages a list of KBs
  # --------------------------------------
  /projects/{projectId}/kbs:
    get:
      summary: List knowledge bases in a project
      description: |
        Returns **non-deleted** knowledge bases for the project. Use this to discover KB IDs before
        uploading documents, running search, or configuring webhooks. Results are cursor-paginated;
        use **`limit`**, **`cursor`**, **`sort`**, and **`order`**. Requires **project access token** with scope **`kb:read`**.

        **Auth semantics:** **`401`** when the bearer token is missing/invalid/expired, or when path **`projectId`**
        does not match the token’s project (**`sub`**). **`404`** **`project_not_found`** when the project row is missing
        or **soft-deleted** (even if the token was issued before deletion).

        After a successful rate-limit check, error responses (**`400`**, **`404`**, **`500`**, **`503`**) still include
        **`X-RateLimit-*`** headers when rate limiting is enabled.
      operationId: listProjectKbs
      tags: [KnowledgeBases]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed values → **`422`**. Must match the project embedded in the token’s **`sub`**;
            otherwise **`401`**. When the project does not exist or is soft-deleted → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: |
            Maximum number of items to return in a single page.
            - Minimum: 1
            - Maximum: 100
            - Default: 20
            
            Use pagination with the `cursor` parameter to retrieve additional pages.
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            **`nextCursor`** from the prior response (last **KB `id`** on that page). Must belong to this project.
            Omit for the first page. Invalid UUIDs or KBs outside the project → **`400`** (**`invalid_cursor`**).
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [createdAt, updatedAt] }
          description: |
            Field to order by. Omit for **`createdAt`**. Invalid values → **`400`** (query validation).
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: |
            Sort order for results.
            - `asc`: Ascending order (oldest first)
            - `desc`: Descending order (newest first, default)
      responses:
        "200":
          description: Knowledge bases page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedKnowledgebases" }
        "400":
          description: |
            Invalid query parameters (**`limit`**, **`order`**, **`sort`**, **`cursor`** shape) or **`invalid_cursor`**
            when **`cursor`** is not a KB **`id`** in this project (or the KB is soft-deleted).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`kb:read` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Project not found or soft-deleted (**`project_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Malformed **`projectId`** in the path (not a valid UUID).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`kb_list_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    post:
      summary: Create a knowledge base in a project
      description: |
        Create a knowledge base. The JSON body is validated against **`KnowledgebaseCreateRequest`**;
        every field under **`settings`** that appears in the OpenAPI schema is persisted and returned
        on **`GET`**. Those settings control how **uploaded documents** are parsed, chunked, and indexed,
        and how **search** behaves for this KB (including optional reranking via **`search.rerankConfig`**).

        **Structured document model:** When **`json`** is among **`settings.parse.outputFormats`**, ingest **always**
        produces a structured representation of the document (elements, tables, figures, and relationships) after a successful JSON parse
        (no separate KB toggle).

        **Server-side defaults on create:** If you omit **`settings`**, or omit pieces of it, the API
        stores normalized defaults: **`pipeline`** `["parse","chunk","index"]`, **`parse.outputFormats`**
        `["markdown","json"]`, and **`index.embeddingConfig`** `{ "model": "openai:text-embedding-3-small" }`.
        Other branches (**`chunk`**, **`search`**, **`parse.pdf`**, nested
        **`embeddingConfig.config`**) are stored exactly as sent (when present).

        **Containers:** Use the documented object shapes (e.g. nested
        required **`type`** when that object is present). Unknown top-level keys under **`settings`** are rejected.

        Requires scope **`kb:write`**.

        **Name:** **`name`** must be **unique within the project** (see **`409`** **`kb_name_duplicate`**).

        **Idempotency:** Optional **`Idempotency-Key`**. Cache is scoped to **project** and a **body fingerprint**
        (**`name`**, **`description`**, and **`settings`** after request validation and applied defaults). The same key with a
        **different** body uses a **separate** cache entry. **201**, **404** (**`project_not_found`**), and **409**
        (**`kb_name_duplicate`**) may be replayed from the idempotency cache (24h TTL). **500** / **503** are not cached. If idempotency storage is
        unavailable, duplicate suppression is skipped. Header value is **trimmed**; whitespace-only is treated as absent.

        The API verifies the project exists and is **not soft-deleted** before create (**`404`** **`project_not_found`**).
        Path **`projectId`** must match the token’s **`sub`** (**`401`** otherwise). Malformed path UUID → **`422`**.
      operationId: createProjectKb
      tags: [KnowledgeBases]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`422`**. Must match token **`sub`**; else **`401`**. Missing/soft-deleted project → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Optional client-supplied key (e.g. UUID). **Trimmed**; whitespace-only treated as absent.

            When set, **201** (created KB JSON), **404** (**`project_not_found`**), and **409** (**`kb_name_duplicate`**) may be
            cached for **24 hours**, keyed by project and a **body fingerprint** (validated **`name`**,
            **`description`**, **`settings`**). Same key + **different** JSON → **separate** cache entry.

            **Replay:** Retries may receive cached **201**, **404**, or **409** problem body. **500** / **503** are not stored.

            **Degraded mode:** If idempotency storage is unavailable, the route runs without duplicate suppression.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgebaseCreateRequest" }
            examples:
              basic:
                summary: Basic KB with defaults (full pipeline)
                value:
                  name: "my-knowledge-base"
                  description: "A knowledge base for document processing"
                  settings:
                    pipeline: [parse, chunk, index]
              withPipeline:
                summary: Explicit pipeline (parse, chunk, index)
                value:
                  name: "full-pipeline-kb"
                  description: "KB with full pipeline; index implies chunk"
                  settings:
                    pipeline: [parse, chunk, index]
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
              parseAndChunkOnly:
                summary: Parse and chunk only (no indexing)
                value:
                  name: "chunks-only-kb"
                  description: "Only parse and chunk; use Get Document Chunks API to retrieve chunks"
                  settings:
                    pipeline: [parse, chunk]
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
              advanced:
                summary: Advanced configuration with Cohere embeddings
                value:
                  name: "advanced-kb"
                  description: "Knowledge base with custom parsing and chunking using Cohere embeddings"
                  settings:
                    pipeline: [parse, chunk, index]
                    parse:
                      outputFormats: ["markdown", "json"]
                      pdf:
                        isOcrEnabled: "yes"
                        tableStructureMode: "ACCURATE"
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
                    index:
                      embeddingConfig:
                        model: "cohere:embed-v3"
                        config:
                          input_type: "search_document"
              awsNative:
                summary: AWS-native stack configuration
                value:
                  name: "aws-kb"
                  description: "Knowledge base using AWS Titan embeddings for AWS-native infrastructure"
                  settings:
                    pipeline: [parse, chunk, index]
                    parse:
                      outputFormats: ["markdown"]
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
                    index:
                      embeddingConfig:
                        model: "aws:titan-embed-text-v1"
              gcpNative:
                summary: GCP-native stack configuration
                value:
                  name: "gcp-kb"
                  description: "Knowledge base using Google embeddings for GCP infrastructure"
                  settings:
                    pipeline: [parse, chunk, index]
                    index:
                      embeddingConfig:
                        model: "google:text-embedding-004"
              fullConvertOptions:
                summary: Advanced parse + chunk (typed convert and chunk settings)
                value:
                  name: "convert-parity-kb"
                  description: "KB with parse.pdf and chunk tuning for PDFs and hybrid chunking"
                  settings:
                    pipeline: [parse, chunk, index]
                    parse:
                      outputFormats: ["markdown"]
                      pdf:
                        tableStructureMode: "ACCURATE"
                        isOcrEnabled: "yes"
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 512
                        mergePeers: true
                    index:
                      embeddingConfig:
                        model: "openai:text-embedding-3-small"
              voyageExample:
                summary: KB with Voyage AI embeddings
                value:
                  name: "voyage-kb"
                  description: "Knowledge base using Voyage AI embeddings for retrieval"
                  settings:
                    pipeline: [parse, chunk, index]
                    parse:
                      outputFormats: ["markdown"]
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
                    index:
                      embeddingConfig:
                        model: "voyage:voyage-3"
                        config:
                          truncation: true
                          output_dtype: float
              hybridChunkDefaultTokenBudget:
                summary: Hybrid chunking default token budget (no strategy.size)
                description: |
                  For **hybrid** chunking, you can omit **`chunk.strategy.size`** to use the platform’s default token budget per chunk.
                value:
                  name: "hybrid-default-budget-kb"
                  description: "Hybrid chunks using backend default token budget"
                  settings:
                    pipeline: [parse, chunk, index]
                    parse:
                      outputFormats: ["markdown"]
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        mergePeers: true
                    index:
                      embeddingConfig:
                        model: "openai:text-embedding-3-small"
              customFileSizeLimit:
                summary: KB with a raised per-KB file size limit (25 MB)
                description: |
                  Use **`settings.maxFileSizeMb`** to override the server default (10 MB) for this KB only.
                  Files and URL-fetched content larger than this value return **`413 file_too_large`**.
                  The value must be an integer greater than 1 (minimum 2).
                value:
                  name: "large-doc-kb"
                  description: "Knowledge base that accepts documents up to 25 MB"
                  settings:
                    pipeline: [parse, chunk, index]
                    maxFileSizeMb: 25
      responses:
        "201":
          description: |
            Knowledge base created. May be replayed from the idempotency cache when **`Idempotency-Key`** matches the same
            body fingerprint within the TTL.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Knowledgebase" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`kb:write` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Project not found or soft-deleted (**`project_not_found`**). May be replayed from the idempotency cache when
            **`Idempotency-Key`** matches the same body fingerprint within the TTL.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            A knowledge base with the same **`name`** already exists in this project (**`kb_name_duplicate`**). May be
            replayed from the idempotency cache when **`Idempotency-Key`** matches the same body fingerprint within the TTL.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Malformed path **`projectId`** or invalid JSON/body (**`invalid_input`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**). Not cached for idempotency.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected failure (**`kb_creation_failed`** or unexpected server error). Not cached for idempotency.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}:
    get:
      summary: Get knowledge base in a project
      description: |
        Fetch a single **non–soft-deleted** knowledge base by **`kbId`**. Returns **`name`**, **`description`**, full
        **`settings`** (embedding model, chunk config, parse options), and timestamps. Requires **project access token**
        with scope **`kb:read`**.

        **Project gate:** The project must exist and **not** be soft-deleted (**`404`** **`project_not_found`**), aligned
        with **`GET /projects/{projectId}/kbs`**.

        **Caching:** Successful **200** responses may be served from a **short-lived server cache** (typically minutes)
        keyed by project + KB; **`PATCH`** and **`DELETE`** on this KB invalidate that entry. Not a substitute for
        **`Cache-Control`** on the wire—clients should not assume strong freshness without **`PATCH`**.

        After a successful rate-limit check, error responses include **`X-RateLimit-*`** when rate limiting is enabled.
      operationId: getProjectKb
      tags: [KnowledgeBases]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`422`**. Must match token **`sub`**; else **`401`**. Soft-deleted
            or missing project → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`422`**. Wrong id or soft-deleted KB → **`404`**
            (**`kb_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Knowledge base (may be served from brief server-side cache; see operation description).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Knowledgebase" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`kb:read` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`project_not_found`** (project missing or soft-deleted) or **`kb_not_found`** (KB missing, wrong project, or
            soft-deleted).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Malformed **`projectId`** or **`kbId`** in the path (not valid UUIDs).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`kb_get_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    delete:
      summary: Delete knowledge base (soft by default)
      description: |
        Delete a knowledge base. Default is **soft delete** (sets **deletedAt**; documents and related data remain
        until **hard** delete or retention policies apply). **`hard=true`** permanently deletes the KB and **associated
        snapshots, and other dependent resources). **Irreversible.**

        Requires project access token with **`kb:write`**. Path **`projectId`** must match token **`sub`** (**`401`**
        otherwise). The project must exist and **not** be soft-deleted (**`404`** **`project_not_found`**).

        **Repeat DELETE:** After a **soft** delete, **`GET …/kbs/{kbId}`** no longer returns the KB; a second **DELETE**
        returns **`404`** **`kb_not_found`** (not idempotent **`204`**).

        Server-side **GET** cache for this KB is invalidated on **204** success. After a successful rate-limit
        check, error responses include **`X-RateLimit-*`** when rate limiting is enabled.

        **Note:** This operation does **not** return **`409`**. Unexpected failures surface as **`500`**
        (**`kb_delete_failed`**) or **`503`** (**`service_unavailable`**).
      operationId: deleteProjectKb
      tags: [KnowledgeBases]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`422`**. Must match token **`sub`** (**`401`** otherwise).
            Missing/soft-deleted project → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`422`**. Unknown id, wrong project, or **already
            soft-deleted** KB → **`404`** (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: hard
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: |
            Deletion mode.
            - `false` (default): Soft delete - marks the resource as deleted but retains data for recovery
            - `true`: Hard delete - permanently removes the resource and all associated data
            
            Soft deletion allows recovery within a retention period. Hard deletion is irreversible.

            **`hard=true`** permanently removes the KB and its dependent resources in one operation (not a staged delete of individual documents first).
      responses:
        "204":
          description: |
            Deleted (soft or hard). Empty body. A second **DELETE** after **soft** delete returns **`404`**
            **`kb_not_found`**.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`kb:write` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`project_not_found`** (project missing or soft-deleted) or **`kb_not_found`** (unknown id, wrong project,
            already soft-deleted KB, or not found).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Malformed **`projectId`** or **`kbId`** in the path.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "400":
          description: Invalid **`hard`** query parameter (when validation fails).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`kb_delete_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    patch:
      summary: Update knowledge base configuration
      description: |
        Partially updates a knowledge base's configuration (**`name`**, **`description`**, and/or **`settings`**).

        **Omit** a top-level JSON key to leave that field unchanged. Omitting **`settings`** entirely means stored settings are **not** read, merged, or re-normalized (no accidental writes). Sending **`"settings": {}`** merges an empty object and then applies the same **canonical defaults** as **POST** create (pipeline, parse formats, chunk, index, search rerank, embedding config).

        This endpoint updates configuration only; existing documents are not automatically reprocessed. Use the document reprocess endpoints to rebuild derived parsed/chunks after changes.

        Requires scope **`kb:write`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). The project must exist and **not** be soft-deleted (**`404`** **`project_not_found`**).

        A body of **`{}`** (no recognized keys) returns **`200`** with the current KB and performs **no** field updates and **no** cache refresh.

        After a successful change (**`name`**, **`description`**, or **`settings`**), server-side **GET** cache for this KB is invalidated. Malformed JSON uses **`422`** with **`invalid_input`**.
      operationId: patchProjectKb
      tags: [KnowledgeBases]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`422`**. Must match token **`sub`** (**`401`** otherwise).
            Missing/soft-deleted project → **`404`** (**`project_not_found`**).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`422`**. Unknown id, wrong project, or **already
            soft-deleted** KB → **`404`** (**`kb_not_found`**).
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: |
                At least one property is recommended; **`{}`** is accepted (no-op **`200`**). **`settings`**, when
                present, must be a JSON **object** (not an array or string). Do not send **`settings: null`**.
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 255
                  pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
                  description: |
                    Omit to leave unchanged. **Not** nullable. Must stay unique within the project; a collision with
                    another KB’s name returns **`409`** **`kb_name_duplicate`**.
                description:
                  type: string
                  nullable: true
                  default: null
                  description: |
                    Replace the KB’s **`description`** when this key is present. Send **`null`** to clear the stored
                    description. Omit the key entirely to leave it unchanged.
                settings:
                  description: |
                    Partial **`Knowledgebase.settings`** update: deep-merged into the stored profile when this key is present.
                    Shape is **`KnowledgebaseSettingsPatch`** (optional **`pipeline`**, **`parse`**, **`chunk`**, **`index`**, **`search`**, **`maxFileSizeMb`**).
                    Use **`{}`** to force server **canonical defaults** for omitted nested keys while preserving other KB fields.
                  $ref: "#/components/schemas/KnowledgebaseSettingsPatch"
                example:
                  settings:
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
            examples:
              patchChunkSettings:
                summary: Update chunking strategy
                value:
                  settings:
                    chunk:
                      attachMetadata: true
                      strategy:
                        method: "hybrid"
                        size: 800
              raiseFileSizeLimit:
                summary: Raise the per-KB file size limit to 25 MB
                description: |
                  Overrides the server default (10 MB) for this KB only.
                  Applies to both file uploads and URL-fetched content.
                value:
                  settings:
                    maxFileSizeMb: 25
              clearFileSizeLimit:
                summary: Clear the per-KB file size limit (revert to server default)
                value:
                  settings:
                    maxFileSizeMb: null
      responses:
        "200":
          description: |
            Full **`Knowledgebase`** JSON after an update, or the current KB when the body is **`{}`** (no-op: no persisted changes,
            no cache refresh).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Knowledgebase" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`kb:write` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`project_not_found`** (project missing or soft-deleted) or **`kb_not_found`** (unknown id, wrong project, or
            soft-deleted KB).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            **`kb_name_duplicate`** — **`name`** would collide with another knowledge base in the same project.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Malformed path UUIDs, invalid JSON body (**`invalid_input`**), unknown top-level keys (**`strict`**
            body), invalid **`name`** / **`description`**, or **`settings`** that is not a JSON object.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`kb_patch_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # --------------------------------------
  # Documents (PROJECT TOKEN ONLY)
  # --------------------------------------
  /projects/{projectId}/kbs/{kbId}/documents:
    get:
      summary: List documents in a knowledge base
      description: |
        List all documents in the KB. Returns document metadata (id, filename, contentType,
        size). Use this to get document IDs for fetching parsed content or chunks, or to
        build a document inventory. Paginated via `limit` and `cursor` (**`nextCursor`** is the last row’s document **`id`**—pass it back with the same **`sort`** / **`order`** as the page that produced it). Optional **`sort`** (**`createdAt`** | **`updatedAt`**) and **`order`** (**`asc`** | **`desc`**); invalid enum values → **`400`** (**`invalid_input`**). Malformed path UUIDs, **`limit`** out of range, non-UUID **`cursor`**, duplicate query keys, or a **`cursor`** that does not refer to a document in this KB → **`400`** (**`invalid_input`** or **`invalid_cursor`**).
        After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.
        Requires project access token.
      operationId: listDocuments
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: Maximum documents per page (default 20, max 100).
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            **`nextCursor`** from the prior response (last **document `id`**). Must belong to this KB. Omit for the first page.
            Invalid → **`400`** (**`invalid_cursor`**).
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [createdAt, updatedAt] }
          description: |
            Field to order by. Omit for **`createdAt`**. Other values → **`400`**.
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: |
            Sort order for results.
            - `asc`: Ascending order (oldest first)
            - `desc`: Descending order (newest first, default)
      responses:
        "200":
          description: Documents page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedDocuments" }
        "400":
          description: |
            Invalid path or query parameters (**`invalid_input`**), or **`invalid_cursor`** when the cursor is not a document in this KB.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base not found (or not in this project)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`document_list_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    post:
      summary: Ingest documents into the knowledge base (files or URLs)
      description: |
        Ingest one or more documents into the knowledge base for processing.
        Requires scope **`docs:ingest`**. Path **`projectId`** must match the token’s project (**`sub`**).

        The request body format is determined by the **`Content-Type`** header:

        | `Content-Type` | Body | Behaviour |
        |---|---|---|
        | `multipart/form-data` | One or more `file` parts | Files are uploaded directly from the client |
        | `application/json` | `{ "urls": ["https://..."] }` | The server fetches each URL server-side and ingests the content |

        Both modes feed into the same parse → chunk → index pipeline and return the same **`201`** response shape.

        ---

        **File upload (`multipart/form-data`):**

        Send one or more `file` parts (up to 10 per request). Each file must be within the size limit and have a
        supported MIME type.

        **URL ingest (`application/json`):**

        Send `{ "urls": ["https://example.com/doc.pdf", ...] }` (up to 10 URLs). The server performs a server-side
        HTTP(S) fetch for each URL (30 s timeout), uploads the response body to blob storage, then enqueues processing
        exactly as with a file upload. Requirements:

        - URLs must use **`http`** or **`https`** and be **publicly reachable**.
        - Private and loopback addresses are rejected with **`400`** (**`invalid_url`**).
        - A non-2xx response or timeout returns **`400`** (**`url_fetch_failed`** / **`url_fetch_timeout`**).
        - The filename is derived from the URL path segment, the `Content-Disposition` header, or the hostname as a fallback.
        - The same size, MIME-type, and filename-dedupe rules apply as for files.

        ---

        **Batch semantics:** Documents are processed **in order**. If one item fails, **earlier items in the same request
        may already be stored** (document rows, blobs, jobs). Re-ingest only the failing item, or use single-item
        requests for all-or-nothing behavior.

        **Filename dedupe:** If the KB already has an **active** (non-deleted) document with the **same `filename`**,
        the API does **not** store the bytes again. The response item includes **`skipped: true`**, the existing
        **`document`**, and a **`jobId`**. To replace bytes under the same name, soft-delete the old document first
        or use **`PUT …/documents/{documentId}/content`**.

        **Supported content types:**
        
        - **Rich Documents**: PDF, DOCX (Microsoft Word), PPTX (PowerPoint)
        - **Markup**: HTML, Markdown, AsciiDoc, WebVTT
        - **Tabular**: XLSX (Excel), CSV
        - **Images**: PNG, JPEG, TIFF, BMP, WEBP (requires OCR for text extraction)
        - **Audio**: MP3, WAV (requires transcription configuration)
        
        **Processing Pipeline:**
        
        After ingestion, documents go through the following stages:
        
        1. **Parsing**: The API converts the document using the KB’s **`parse.outputFormats`** and PDF shortcuts under
           **`parse.pdf`** (OCR, table mode, engine and languages).
           - Output is stored for each format listed in **`parse.outputFormats`**.
        
        2. **Chunking**: Content is split using the KB’s **`chunk`** settings.
           - Method is **`hybrid`** or **`hierarchical`** (see knowledge base schema).
           - Optional metadata (headers, captions, etc.) is included when enabled.
           - Chunks use the same **output format** as the parsed document.
        
        3. **Indexing**: Chunks are embedded using the KB’s **`index.embeddingConfig`** and indexed for **`POST …/search`**.
        
        **Parse-stage settings (KB schema):**
        
        - **`parse.outputFormats`**: which formats are parsed and available for chunks/search.
        - **`parse.pdf`**: PDF/OCR/table options for parsing.
        
        **Job Tracking:**
        
        Each ingested document returns a job ID for tracking processing status via the Jobs API.
        
        **Idempotency (`Idempotency-Key` header):**

        When set, **`201`** responses may be cached (typically **24 hours**). For file uploads the idempotency scope
        includes **`projectId`**, **`kbId`**, the header value, and a **SHA-256** fingerprint of the multipart `file`
        parts (per-part **name**, **size**, declared **type**, and **raw bytes**, in request order). For URL ingest it
        includes **`projectId`**, **`kbId`**, the header value, and the serialized URL list. Retries with the same key
        but different content do **not** replay a prior response. **`500`** / **`503`** outcomes are **not** cached.
        If idempotency storage is unavailable, replay is best-effort only.

        **Limits & Batching:**

        - Maximum items per request: **10** by default (files or URLs)
        - Maximum size per item: **10 MB** by default
        - Deployment-specific ceilings may differ; **`413`** (**`file_too_large`**, **`too_many_files`**, **`too_many_urls`**) reflects the limits in effect for your environment.

        After a successful rate-limit check, **`X-RateLimit-*`** (and **`Retry-After`** on **`429`**) are attached to
        **201** and to documented error responses for this operation.
      operationId: uploadDocument
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Unknown or not in this project, or soft-deleted
            KB → **`404`** (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Optional. Trimmed; empty/whitespace is ignored. When set, **`201`** may be replayed from the idempotency cache for the same
            **`projectId`**, **`kbId`**, key, and multipart payload fingerprint (see operation description). **`500`** /
            **`503`** are not cached.

      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              description: |
                Multipart form data for file upload.
                Send one or more `file` parts to upload multiple documents in a single request.
                Each file will be processed according to the Knowledge Base's parse / chunk / index settings
                and will receive its own job.
              properties:
                file:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: |
                    One or more document files to upload and process.
                    
                    **Supported file formats**:
                    - Rich Documents: PDF, DOCX (Microsoft Word), PPTX (PowerPoint)
                    - Markup: HTML, Markdown, AsciiDoc, WebVTT
                    - Tabular: XLSX (Excel), CSV
                    - Images: PNG, JPEG, TIFF, BMP, WEBP (requires OCR configuration)
                    - Audio: MP3, WAV (requires transcription configuration)
                    
                    **File size limits**:
                    - Default maximum per file: 10 MB
                    - Default maximum files per request: 10
                    - Actual limits may vary per **deployment**; check your operator’s documentation if you need exact caps.
                    
                    **Processing**: The file will be parsed, chunked, embedded, and stored
                    according to the KB's configuration. A job ID is returned for tracking progress.
          application/json:
            schema:
              type: object
              required: [urls]
              description: |
                Ingest documents by providing a list of URLs.
                The server fetches each URL, uploads the content to blob storage,
                and processes it through the same parse / chunk / index pipeline as file uploads.
                Each URL receives its own job ID in the response.
              properties:
                urls:
                  type: array
                  items:
                    type: string
                    format: uri
                  minItems: 1
                  maxItems: 10
                  description: |
                    One or more URLs to fetch and ingest as documents.

                    **Supported content types**: same as file upload (PDF, DOCX, HTML, Markdown, images, audio, etc.).

                    **Size limits**: Default maximum 10 MB per URL response, 10 URLs per request.
                    The server performs a server-side fetch; URLs must be publicly reachable over HTTP or HTTPS.
                    Private/loopback addresses are rejected with **`400`** (**`invalid_url`**).

                    **Filename**: Derived from the URL path, `Content-Disposition` header, or hostname as fallback.
      responses:
        "201":
          description: Document(s) ingested successfully (files or URLs)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MultiDocumentUploadResponse" }
        "400":
          description: |
            Validation or fetch failure. Error codes by mode:

            **File upload (`multipart/form-data`):**
            - **`invalid_multipart`** — unreadable multipart body
            - **`missing_file`** — no `file` part present
            - **`invalid_filename`** — filename empty, too long, or contains invalid characters

            **URL ingest (`application/json`):**
            - **`invalid_json`** — request body is not valid JSON
            - **`invalid_input`** — `urls` field missing, empty, or contains a non-URL string
            - **`invalid_url`** — a URL is not `http`/`https`, or targets a private/loopback address
            - **`url_fetch_failed`** — remote server returned a non-2xx status
            - **`url_fetch_timeout`** — remote server did not respond within 30 s
            - **`invalid_filename`** — derived filename is invalid

            **Both modes:**
            - Malformed path UUIDs (**`invalid_input`**)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:ingest` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** — knowledge base missing, not in this project, or soft-deleted.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "413":
          description: |
            Payload too large:
            - **`file_too_large`** — a file or URL response body exceeds the effective per-item size cap. The limit
              is the KB's **`settings.maxFileSizeMb`** when set, otherwise the server default (**10 MB**). Applies
              to both `multipart/form-data` uploads and server-fetched URL content.
            - **`too_many_files`** — more than the per-request file limit (default **10**)
            - **`too_many_urls`** — more than the per-request URL limit (default **10**)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "415":
          description: |
            **`unsupported_file_format`** — the content type of the uploaded file or the fetched URL response is not
            in the API allowlist (PDF, DOCX, HTML, Markdown, images, audio, etc.).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable when persisting document or job (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Blob storage failure (**`blob_upload_failed`**), document/job persistence failure (**`document_creation_failed`**,
            **`job_creation_failed`**), or unexpected server error. Applies to both file uploads and URL ingest.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}:
    get:
      summary: Get a document
      description: |
        Fetch a single document's metadata by ID (filename, contentType, size, kbId). Use
        this to verify a document exists or to display it in a UI. For parsed content or
        chunks, use the `/parsed` (set `Accept` for representation; no query params) and `/chunks`
        sub-resources. Path UUIDs must be valid; missing KB for this project → **`404`** (**`kb_not_found`**); unknown id, wrong KB, or **soft-deleted** document → **`404`** (**`document_not_found`**).
        After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.
        Requires project access token.
      operationId: getDocument
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: Document identifier.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Document
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "400":
          description: Malformed path UUIDs (**`invalid_input`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents, which are not returned by this route).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`document_get_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    delete:
      summary: Delete a document (soft by default)
      description: |
        Delete a document. Default is **soft delete** (`**deletedAt**`); use **`hard=true`** to remove stored metadata and
        delete the source blob (best-effort).

        **Derived data** for this document is removed (parsed outputs, structured elements, chunks, embeddings, and jobs tied to that document).

        **Concurrency:** If a **processing job** is still active for this document, the API returns **`409`**
        (**`document_in_process`**) until you cancel that job.

        **Repeat DELETE:** After **soft** delete, **`GET …/documents/{documentId}`** returns **`404`**; a second **DELETE**
        returns **`404`** **`document_not_found`**.

        Requires **`docs:delete`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). After a
        successful rate-limit check, **`X-RateLimit-*`** are included on **204** and on documented errors.
      operationId: deleteDocument
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Missing or soft-deleted KB for this project →
            **`404`** (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: |
            Document identifier (**UUID**). Malformed → **`400`**. Unknown id, wrong KB, or **already soft-deleted** →
            **`404`** (**`document_not_found`**).
          schema: { type: string, format: uuid }
        - name: hard
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: |
            Deletion mode.
            - `false` (default): Soft delete — sets **`deletedAt`**; source blob retained until hard delete or retention.
            - `true`: Hard delete — removes the document row and attempts to delete the blob; irreversible.

            Invalid coercion for **`hard`** → **`400`**.
      responses:
        "204":
          description: Deleted (empty body).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "400":
          description: Malformed path UUIDs or invalid **`hard`** query.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:delete` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including repeat delete after soft delete).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            **`document_in_process`** — cancel the active job for this document, then retry.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`document_delete_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/content:
    get:
      summary: Get the original document file
      description: |
        Downloads the **original uploaded file bytes** from blob storage (same artifact as ingest).

        **`Content-Type`** is the stored document MIME type when known (e.g. **`application/pdf`**); if empty in
        metadata, the API uses **`application/octet-stream`**. **`Content-Length`** matches the returned body (byte length
        of the downloaded object). **`Content-Disposition`** is **`attachment`** with an ASCII **`filename`** fallback
        and RFC 5987 **`filename*`** (UTF-8) for Unicode names. **`Cache-Control: private, max-age=0`** discourages shared
        caching.

        Use this for previews, audits, or re-download workflows. **MCP** does not expose raw bytes—call this route over HTTP.

        Requires **`docs:read`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). After a successful
        rate-limit check, **`X-RateLimit-*`** are included on **200** and on documented errors.
      operationId: getDocumentContent
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Missing or soft-deleted KB → **`404`**
            (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: |
            Document identifier (**UUID**). Malformed → **`400`**. Unknown id, wrong KB, or soft-deleted document →
            **`404`** (**`document_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: |
            Raw file bytes. Treat as binary; inspect **`Content-Type`** for the MIME type. **`Content-Disposition`** and
            **`Content-Length`** are always set as described in the operation summary.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Content-Type:
              schema:
                type: string
              description: Stored MIME type or **`application/octet-stream`** when unset.
            Content-Length:
              schema:
                type: integer
                minimum: 0
              description: Byte length of the response body (matches downloaded blob size).
            Content-Disposition:
              schema:
                type: string
              description: |
                **`attachment`** with **`filename`** (ASCII fallback) and **`filename*`** (UTF-8, RFC 5987).
            Cache-Control:
              schema:
                type: string
              description: "**`private, max-age=0`**"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
              description: |
                Binary body. Clients may also observe other **`Content-Type`** values that match the uploaded file
                (OpenAPI lists **`application/octet-stream`** as the generic binary schema).
        "400":
          description: |
            Malformed path UUIDs, or **`invalid_blob_url`** when the stored blob reference is unusable (local mode).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:read` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "502":
          description: |
            Blob storage read failed (**`blob_download_failed`**)—for example missing object upstream or transport error.
            The document row may still exist while storage is inconsistent.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**) when loading KB or document metadata.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected server error.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

    put:
      summary: Replace a document and re-ingest it
      description: |
        Replaces the stored file bytes for `documentId` and re-runs the knowledge base pipeline.

        The API **purges derived outputs first** (parsed text, chunks, vectors, and jobs for this document)
        and creates a new **pending** job. That ordering avoids serving **old chunks or parsed content** after the stored file
        already points at new bytes.

        A new processing job for the updated document is then enqueued automatically and the document is processed through
        the pipeline using the current KB settings.

        If the document is already being processed, the API returns **`409 Conflict`**. Cancel the active job before retrying.

        **`Idempotency-Key`** (optional) is scoped to **project**, **KB**, **`documentId`**, and a **SHA-256 fingerprint** of
        the replacement **`file`** part (same pattern as **`POST …/documents`**). Repeating the same key with **different**
        bytes does **not** replay a prior response.

        Requires **`docs:ingest`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). After a successful
        rate-limit check, **`X-RateLimit-*`** are included on **200** and on documented errors (not on **`401`**/**`403`** when
        the check is skipped). **MCP** does not expose uploads or replacements—use HTTP.

        Processing starts automatically once the job is queued; workers also claim eligible **pending** jobs on their normal schedule.
      operationId: replaceDocumentContent
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Missing or soft-deleted KB → **`404`**
            (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: |
            Document identifier (**UUID**). Malformed → **`400`**. Unknown id, wrong KB, or soft-deleted document →
            **`404`** (**`document_not_found`**).
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Optional. Scoped with **`projectId`**, **`kbId`**, **`documentId`**, and a **body fingerprint** of the **`file`**
            part so retries with the **same** payload replay the cached **200** (24h TTL when idempotency storage is available).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              description: Multipart upload with one replacement `file` part.
              properties:
                file:
                  type: array
                  minItems: 1
                  maxItems: 1
                  items:
                    type: string
                    format: binary
      responses:
        "200":
          description: Replacement queued
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentUploadResponse" }
        "400":
          description: |
            Malformed path UUIDs, unreadable multipart body (**`invalid_multipart`**), missing **`file`** (**`missing_file`**),
            more than one **`file`** part (**`invalid_file_count`**), or invalid filename (**`invalid_filename`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: |
            Missing/invalid **`Authorization`**, invalid or expired project token, or path **`projectId`** does not match
            the token’s project (**`sub`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:ingest` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            **`document_in_process`** — an ingest/replace/reprocess job is still **pending**, **parsing**, **chunking**, or **indexing**
            for this document; cancel it, then retry.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "413":
          description: |
            **`file_too_large`** — per-file cap (default **10 MB**; may vary by deployment).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "415":
          description: |
            **`unsupported_file_format`** — part **`Content-Type`** (or empty type) is not in the upload allowlist.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "502":
          description: |
            Blob / storage upload failure (**`blob_upload_failed`**) or another **`BlobServiceError`** from the storage layer.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: |
            Service temporarily unavailable when purging derived data, updating the document row, or creating the job
            (**`service_unavailable`**). The API may revert the document row to the prior blob reference when job creation fails.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            **`document_replace_purge_failed`**, **`document_update_failed`**, **`job_creation_failed`** (after best-effort cleanup),
            or unexpected server error.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/reprocess:
    post:
      summary: Reprocess a single document
      description: |
        Re-runs parsing/chunking/indexing for a single existing document using the current knowledge base settings.

        Before the new processing job starts, the API deletes all references to the **products of the prior processing run**
        references derived from this document).

        A new processing job is then enqueued automatically and the document is processed through the pipeline using the
        current KB settings.


        If the document is already being processed, returns **409**. Cancel the active job before retrying.

        Requires **`docs:ingest`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). After a successful
        rate-limit check, **`X-RateLimit-*`** are included on **202** and on documented errors (not on **`401`**/**`403`** when
        the check is skipped). **MCP** does not expose reprocess—use HTTP.
      operationId: reprocessDocument
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Missing or soft-deleted KB → **`404`**
            (**`kb_not_found`**).
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: |
            Document identifier (**UUID**). Malformed → **`400`**. Unknown id, wrong KB, or soft-deleted document →
            **`404`** (**`document_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Reprocess queued
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentUploadResponse" }
        "400":
          description: |
            Malformed path UUIDs (**`invalid_input`**). If a body is sent, it must be valid JSON and an empty object
            **`{}`** (legacy); omitting the body is preferred.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:ingest` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: |
            **`document_in_process`** — a job is still **pending**, **parsing**, **chunking**, or **indexing** for this document.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**) during purge or job creation.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            **`document_reprocess_purge_failed`**, **`job_creation_failed`**, or unexpected server error.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/reprocess:
    post:
      summary: Reprocess all documents in a knowledge base
      description: |
        Re-runs parsing/chunking/indexing for every existing (non-deleted) document in this knowledge base.

        Returns a mix of accepted documents and per-document conflicts when a document is already in process.

        **Fail-fast:** The API iterates documents sequentially. If any document reprocess fails with a non-**409** error,
        the request returns that error immediately. Documents already queued earlier in the run remain queued; later
        documents are not attempted.
        
        Requires **`docs:ingest`**. Path **`projectId`** must match token **`sub`** (**`401`** otherwise). After a successful
        rate-limit check, **`X-RateLimit-*`** are included on **202** and on documented errors (not on **`401`**/**`403`** when
        the check is skipped). **MCP** does not expose reprocess—use HTTP.
      operationId: reprocessDocuments
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: |
            Project identifier (**UUID**). Malformed → **`400`**. Must match token **`sub`** (**`401`** otherwise).
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: |
            Knowledge base identifier (**UUID**). Malformed → **`400`**. Missing or soft-deleted KB → **`404`**
            (**`kb_not_found`**).
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Reprocess queued (per-document)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [accepted, conflicts]
                properties:
                  accepted:
                    type: array
                    items:
                      type: object
                      required: [documentId, jobId, createdAt]
                      properties:
                        documentId: { type: string, format: uuid }
                        jobId: { type: string, format: uuid }
                        createdAt: { type: string, format: date-time }
                  conflicts:
                    type: array
                    items:
                      type: object
                      required: [documentId, message]
                      properties:
                        documentId: { type: string, format: uuid }
                        message: { type: string }
        "400":
          description: |
            Malformed path UUIDs (**`invalid_input`**). If a body is sent, it must be valid JSON and an empty object
            **`{}`** (legacy); omitting the body is preferred.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:ingest` required)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base not found (or not in this project)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**) during KB load or job creation.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected server error.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/parsed:
    get:
      summary: Get parsed content of a document
      description: |
        Returns the **raw body** of the parsed document. Parsing must have completed successfully
        (check job status). The response **`Content-Type`** matches the chosen representation.

        **No query parameters** (unknown or duplicate query keys → **`400`** **`invalid_input`**). Choose the stored format with the **`Accept`** header. Only the
        **first** media range is used (split on `,`, strip parameters after `;`).

        **Allowed primary `Accept` values** (case-insensitive):

        | Accept | Stored format |
        |--------|----------------|
        | `text/markdown` | markdown |
        | `text/html` | html |
        | `text/plain` | text |
        | `application/json` | json |
        | `application/x-doctags` | doctags |

        If **`Accept` is omitted** or the first range is unknown, the server uses **`text/markdown`**.

        Missing KB → **`404`** **`kb_not_found`**; missing or soft-deleted document → **`404`** **`document_not_found`**; document exists but no row for the chosen format → **`404`** **`parsed_not_found`**. For chunked content use **`/chunks`**.
        After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers (including **`200`** with **`Cache-Control: private, max-age=86400`** on the body).
      operationId: getParsedDocument
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: Document identifier.
          schema: { type: string, format: uuid }
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            default: text/markdown
          description: |
            One of **`text/markdown`**, **`text/html`**, **`text/plain`**, **`application/json`**, **`application/x-doctags`**.
            Only the first media range in the header is used. Omitted or unrecognized → **`text/markdown`**.
          example: text/markdown
      responses:
        "200":
          description: Parsed document body (plain). `Content-Type` matches the representation.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            text/markdown:
              schema: { type: string }
            text/html:
              schema: { type: string }
            text/plain:
              schema: { type: string }
            application/json:
              schema: { type: string }
            application/x-doctags:
              schema: { type: string }
        "400":
          description: |
            Malformed path UUIDs (**`invalid_input`**) or any query string (**`invalid_input`** — use **`Accept`** only).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`**, **`document_not_found`**, or **`parsed_not_found`** (no stored body for the format implied by **`Accept`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`document_parsed_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/chunks:
    get:
      summary: Get chunks for a document
      description: |
        Returns **paginated chunks as JSON** (`items`, `nextCursor`, `format`). The response
        is always `application/json`; use the **`format` query parameter** to choose how each
        chunk's `content` string is derived (`markdown`, `html`, `text`, `json`, `doctags`).
        Default is `markdown`. Every value—including `json`—must be listed in the KB's
        `parse.outputFormats` (documents must have been parsed and chunked in that format).
        If the requested format was not configured on the KB, the API returns **422**
        **`format_not_available`** with a message that lists the available formats.

        When no chunk rows exist yet for the document’s latest completed chunking job, the API returns **422**
        **`chunking_not_completed`** (not **404**).

        **Pagination:** use **`limit`** and **`cursor`**; pass **`nextCursor`** from the previous page as **`cursor`** (opaque cursor for this route). Malformed **`cursor`** → **`400`** (**`invalid_cursor`**).

        Requires scope **`docs:parsed:read`**. Path **`projectId`** must match the token’s project (**`sub`**). After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.
      operationId: getDocumentChunks
      tags: [Documents]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: Document identifier.
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: |
            Maximum number of chunks to return in a single page.
            - Minimum: 1
            - Maximum: 100
            - Default: 20
            
            Use pagination with the `cursor` parameter to retrieve additional pages.
            For large documents with many chunks, use a smaller limit (e.g., 10-20) for
            better performance and easier processing.
          example: 20
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: |
            Pagination cursor for retrieving the next page of results.
            Returned in the `nextCursor` field of the previous response.
            Omit or use `null` to retrieve the first page.
            
            When `nextCursor` is `null` in the response, there are no more chunks to retrieve.
          example: "eyJkb2N1bWVudElkIjoiLi4uIiwiaW5kZXgiOjIwfQ"
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [markdown, html, text, json, doctags]
            default: markdown
          description: |
            How each chunk's `content` field is built from stored chunk rows (`markdown` column vs `text`, etc.).
            Must match an entry in the KB's `parse.outputFormats`; otherwise **422** with `format_not_available`.
            Not to be confused with the HTTP response type—the response body is always JSON.
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            default: application/json
          description: |
            Response body is always **`application/json`** (paginated chunks). The server does not require this header.
      responses:
        "200":
          description: Document chunks retrieved successfully
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedChunks" }
              examples:
                withMetadata:
                  summary: Chunks with metadata (JSON format)
                  description: |
                    Returns chunks as JSON objects with full metadata including id, index,
                    content, format, size, metadata, and createdAt timestamp.
                  value:
                    items:
                      - id: "550e8400-e29b-41d4-a716-446655440000"
                        documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        index: 0
                        content: "# Introduction\n\nThis document provides an overview of..."
                        format: "markdown"
                        size: 1024
                        metadata:
                          heading: "Introduction"
                        createdAt: "2026-02-20T10:00:00Z"
                      - id: "550e8400-e29b-41d4-a716-446655440001"
                        documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        index: 1
                        content: "## Getting Started\n\nTo begin using the system..."
                        format: "markdown"
                        size: 856
                        metadata:
                          heading: "Getting Started"
                        createdAt: "2026-02-20T10:00:00Z"
                    nextCursor: "eyJkb2N1bWVudElkIjoiLi4uIiwiaW5kZXgiOjJ9"
                    format: markdown
                paginated:
                  summary: Paginated response with cursor
                  description: |
                    When there are more chunks available, the response includes a non-null
                    nextCursor that can be used to retrieve the next page.
                  value:
                    items:
                      - id: "550e8400-e29b-41d4-a716-446655440020"
                        documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        index: 20
                        content: "## Advanced Topics\n\nFor advanced users..."
                        format: "markdown"
                        size: 1200
                        metadata:
                          heading: "Advanced Topics"
                        createdAt: "2026-02-20T10:00:00Z"
                    nextCursor: "eyJkb2N1bWVudElkIjoiLi4uIiwiaW5kZXgiOjIxfQ"
                    format: markdown
                lastPage:
                  summary: Last page of chunks
                  description: |
                    When all chunks have been retrieved, nextCursor is null indicating
                    there are no more pages.
                  value:
                    items:
                      - id: "550e8400-e29b-41d4-a716-446655440099"
                        documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        index: 99
                        content: "## Conclusion\n\nIn summary..."
                        format: "markdown"
                        size: 512
                        metadata:
                          heading: "Conclusion"
                        createdAt: "2026-02-20T10:00:00Z"
                    nextCursor: null
                    format: markdown
        "400":
          description: |
            Invalid path UUIDs (**`invalid_input`**), invalid **`limit`** / **`format`**, duplicate query keys, or **`invalid_cursor`** when **`cursor`** is not valid for this route.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            **`chunking_not_completed`** when no chunk rows exist yet for the document’s latest chunking job, or **`format_not_available`** when **`format`** is not among
            the KB's `parse.outputFormats` (including `json`—it is not implied unless configured).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                formatNotAvailable:
                  summary: Requested format not available
                  value:
                    title: "Unprocessable Entity"
                    status: 422
                    detail: 'The document was not processed in the requested format "json". Available formats for this knowledge base: markdown.'
                    code: "format_not_available"
                chunkingNotCompleted:
                  summary: Chunking not completed or failed
                  value:
                    title: "Chunking Not Completed"
                    status: 422
                    detail: "Document chunking has not completed or failed. Check the job status to verify chunking progress."
                    code: "chunking_not_completed"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`document_chunks_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # --------------------------------------
  # Multimodal document structure (PROJECT TOKEN ONLY)
  # --------------------------------------
  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/elements:
    get:
      summary: List typed elements in a document
      description: |
        Lists **typed multimodal elements** extracted from the document (paragraphs, headings, tables, figures, captions, sections, etc.).

        Elements preserve layout/grounding metadata (**`grounding.pages`**, bounding boxes where available), reading order, and parent/child hierarchy.
        Use this listing when you need **per-document** detail, pagination, or filters not exposed by search alone.

        Use query params to filter by element **`type`** (comma-separated), **`sectionId`** (UUID), or **`page`** (1-indexed; returns elements whose **`grounding.pages`** includes that page). Only documented query keys are allowed (extras → **`400`** **`invalid_input`**).

        Optional **`include`** (comma-separated, case-insensitive): when **omitted or blank**, each item includes **`payload`** and **`grounding`** (same as historical behavior). When set, only listed expansions are included — **`payload`**, **`grounding`**. The flag **`relationships`** is accepted for forward compatibility but does not expand the list payload; use **GET …/elements/{elementId}/relationships** for edges.

        Paginate with **`limit`** and **`cursor`** (**`nextCursor`** is the last element **`id`** UUID on the page; pass it back as **`cursor`** for the next page). Typical agents combine this endpoint with **GET …/sections** and **GET …/relationships**
        for precise citations, then **POST …/search** for RAG when content snippets are enough.

        Path **`projectId`** must match the token’s project (**`sub`**). After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listDocumentElements
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: Document identifier.
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Optional comma-separated element type filter (e.g. `paragraph,table,figure`).
        - name: sectionId
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Optional section subtree filter. Only elements under this section are returned.
        - name: page
          in: query
          required: false
          schema: { type: integer, minimum: 1 }
          description: Optional page filter (1-indexed). Returns elements whose **`grounding.pages`** includes this page number.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: Maximum number of items per page.
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            Opaque pagination cursor: the **`nextCursor`** value from the previous page (an element **`id`** UUID). Omit for the first page.
        - name: include
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated flags: **`payload`**, **`grounding`**, and optionally **`relationships`** (ignored on this list route; use relationship subroutes). When omitted or empty, **`payload`** and **`grounding`** are both included.
      responses:
        "200":
          description: Document elements page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedElements" }
        "400":
          description: |
            Malformed path UUIDs (**`invalid_input`**), unknown query keys, invalid **`limit`**, non-UUID **`cursor`** / **`sectionId`**, invalid **`page`**, or duplicate query keys.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`elements_list_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/elements/{elementId}:
    get:
      summary: Get a single typed element
      description: |
        Fetches one **element** by **`elementId`** after parsing has produced structured elements (requires **`json`** among **`settings.parse.outputFormats`** and a successful JSON parse).
        The base object always identifies **type** and identity; richer fields depend on kind (e.g. table vs figure vs paragraph).

        Only the **`include`** query key is allowed (extras → **`400`** **`invalid_input`**). Comma-separated flags (case-insensitive): **`payload`**, **`grounding`**, **`relationships`**.
        When **`include`** is **omitted or blank**, **`payload`** and **`grounding`** are included (same default as **GET …/elements**). When set, only listed expansions are included; **`relationships`** adds a **`relationships`** array on the JSON body (edges touching this element; capped server-side—use **GET …/elements/{elementId}/relationships** for full pagination).

        Path **`projectId`** must match the token’s project (**`sub`**). After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.

        usual **`404`** when the knowledge base, document, or element is missing.

        Requires scope **`docs:parsed:read`**.
      operationId: getDocumentElement
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          description: Document identifier.
          schema: { type: string, format: uuid }
        - name: elementId
          in: path
          required: true
          description: Element identifier.
          schema: { type: string, format: uuid }
        - name: include
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated: **`payload`**, **`grounding`**, **`relationships`**. Omitted or empty → **`payload`** and **`grounding`** included; **`relationships`** adds **`relationships`** on the response when requested.
      responses:
        "200":
          description: Element (optionally with **`relationships`** when requested via **`include`**).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ElementGetResponse" }
        "400":
          description: Malformed path UUIDs (**`invalid_input`**) or unknown query keys (**only `include` is allowed**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`**, **`document_not_found`**, or **`element_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`element_get_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/tables:
    get:
      summary: List tables in a document
      description: |
        Returns a **paginated inventory** of tables detected during parsing (identifiers, row/column counts, optional **`grounding`**; **`grid`** is omitted—use **GET …/tables/{tableId}** for cell data).

        Each row points at a **`tableId`** you pass to **GET …/tables/{tableId}** for the full grid, normalized rows, or export-oriented
        views; listing stays lightweight so dashboards can scan many documents without pulling every cell.

        Tables also appear as typed **elements** in **GET …/elements**; use this path when UX is table-centric (spreadsheet, data QA)
        rather than generic element streaming.

        Query keys **`limit`** and **`cursor`** only (**`nextCursor`** = last **`table` `id`** UUID on the page). Unknown keys → **`400`** **`invalid_input`**. Path **`projectId`** must match the token’s project (**`sub`**). After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listDocumentTables
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            **`nextCursor`** from the prior response (a **`document_tables.id`** UUID). Omit for the first page.
      responses:
        "200":
          description: Tables page (lightweight rows without **`grid`**).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedTables" }
        "400":
          description: |
            Malformed path UUIDs (**`invalid_input`**), unknown query keys, invalid **`limit`**, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`tables_list_failed`** or unexpected server error).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/tables/{tableId}:
    get:
      summary: Get a single table (structured)
      description: |
        Returns a **single table** as structured JSON suitable for rendering, analytics, or agent tools (not a bitmap of the page).

        Query **`view`** selects the projection: **`grid`** for a row/column/cell matrix, **`rows`** for a flat,
        **pageable** row list (use **`limit`** / **`cursor`** — **`cursor`** is a **decimal row offset** string, e.g. **`0`**, from **`nextCursor`**), and **`normalized`** for a column-oriented shape when you need
        stable headers across irregular visual tables.

        Only documented query keys are accepted (**`strict`**); unknown keys → **`400`** **`invalid_input`**. **`limit`** / **`cursor`** are allowed **only** when **`view=rows`**.

        Large spreadsheets should use **`view=rows`** with cursors to avoid huge single responses; combine with **GET …/artifacts**
        if you also need the raw extraction debug bundle.

        Path **`projectId`** must match the token’s project. **`404`** **`kb_not_found`** / **`document_not_found`** / **`table_not_found`** as applicable. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`**.

        Requires scope **`docs:parsed:read`**.
      operationId: getDocumentTable
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: tableId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: view
          in: query
          required: false
          schema: { type: string, enum: [grid, rows, normalized], default: grid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: Page size for `view=rows`.
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: |
            **`view=rows` only:** **`nextCursor`** from the prior response (non-negative integer string = next row offset). Omit for the first page.
      responses:
        "200":
          description: Table (shape per **`view`**; see **`DocumentTableResponse`**).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentTableResponse" }
        "400":
          description: Malformed path UUIDs, unknown query keys, invalid **`view`**, or invalid **`limit`**/**`cursor`** for **`view=rows`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read`); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`table_not_found`**.'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`table_get_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/figures:
    get:
      summary: List figures in a document
      description: |
        Lists **figures** (charts, photos, diagrams, sub-images) detected during layout analysis, with stable **`figureId`** values
        for follow-up detail calls.

        List items usually include enough metadata to build galleries (page index, thumbnails via artifact URLs when present); binary
        bytes are not inlined here—**GET …/figures/{figureId}** and **GET …/artifacts** expose **`contentUrl`**-style links when available.

        Figures correlate with **figure** / **picture** **elements** in **GET …/elements**; prefer this route when your product treats
        visuals as first-class resources (compliance review, multimodal RAG routing).

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`limit`** and **`cursor`** only (**`nextCursor`** = last figure **`id`** UUID); unknown keys → **`400`** **`invalid_input`**. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listDocumentFigures
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (figure **`id`** UUID).
      responses:
        "200":
          description: Figures page (items may include signed **`contentUrl`** when a **`ready`** binary artifact exists).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedFigures" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`figures_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/figures/{figureId}:
    get:
      summary: Get a single figure
      description: |
        Returns one **figure** record: geometry on the page, classification hints, and references to any stored raster or vector artifacts.

        Use **`include`** (comma-separated) such as **`captions`**, **`artifacts`**, or **`relationships`** to hydrate caption text,
        download URLs, or graph edges in one response when the list endpoint stayed intentionally thin.

        then call here for presentation-ready detail.

        Path **`projectId`** must match the token’s project (**`sub`**). Query key **`include`** only (comma-separated **`captions`**, **`artifacts`**, **`relationships`**); unknown keys or unknown include tokens → **`400`** **`invalid_input`**. When **`include`** requests expansions, the JSON body may include additional top-level keys (**`captions`**, **`artifact`**, **`relationships`**) alongside **`Figure`** fields. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: getDocumentFigure
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: figureId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: include
          in: query
          required: false
          schema: { type: string }
          description: Optional include flags (comma-separated), e.g. `captions,artifacts,relationships`.
      responses:
        "200":
          description: Figure (and optional expansions from **`include`** — see operation description).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Figure" }
        "400":
          description: Malformed path UUIDs, unknown query keys, or invalid **`include`** flags.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`figure_not_found`**.'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`figure_get_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/sections:
    get:
      summary: List sections in a document
      description: |
        Lists **sections** (outline / heading hierarchy) for one document: ids, titles, levels, and ordering suitable for TOC sidebars.

        Sections partition **elements**—filter **GET …/elements** with **`sectionId`** or use **GET …/sections/{sectionId}/elements**
        to fetch only the subtree you are displaying.

        Use this API when you must stay inside one file
        or need deterministic depth control via **GET …/sections/{sectionId}** and its **`depth`** query parameter.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`limit`** and **`cursor`** only; unknown keys → **`400`** **`invalid_input`**. **`nextCursor`** is the last section element **`id`** UUID on the page. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listDocumentSections
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (section element **`id`** UUID).
      responses:
        "200":
          description: Sections page (**`childSectionIds`** lists direct child section ids per item).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedSections" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`sections_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/sections/{sectionId}:
    get:
      summary: Get a section subtree
      description: |
        Returns a **section node** plus optional nested **child sections** up to **`depth`** (0 = node only, larger values walk the tree).

        Use this to expand a TOC entry in one call instead of re-listing the entire document; combine with **GET …/sections/{sectionId}/elements**
        when you also need body content (paragraphs, tables) scoped to that subtree.

        **`depth`** is capped for safety on deeply nested specs; if you need every heading, start from **GET …/sections** and page,
        then drill in here for the branch the user opened.

        Path **`projectId`** must match the token’s project (**`sub`**). Query key **`depth`** only (0–10, default 2); unknown keys → **`400`** **`invalid_input`**. **`childSectionIds`** lists **descendant** section element ids up to **`depth`** levels below this node. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: getDocumentSection
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: sectionId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: depth
          in: query
          required: false
          schema: { type: integer, minimum: 0, maximum: 10, default: 2 }
          description: Depth of subtree to return.
      responses:
        "200":
          description: Section node with **`childSectionIds`** populated per **`depth`**.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Section" }
        "400":
          description: Malformed path UUIDs, unknown query keys, or **`depth`** out of range.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`section_not_found`** (including when **`sectionId`** is not a **`section`** element).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`section_get_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/sections/{sectionId}/elements:
    get:
      summary: List elements under a section
      description: |
        **Convenience wrapper** around element listing that restricts results to the subtree rooted at **`sectionId`** (headings, body blocks,
        embedded tables/figures that belong to that outline branch).

        Prefer this over a broad **GET …/elements** scan when rendering a chapter or contract clause; pagination mirrors the generic
        elements API (**`limit`** / **`cursor`**).

        Ordering follows stored reading order so UI highlights and accessibility labels stay consistent with PDF layout.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`limit`**, **`cursor`**, and optional **`type`** only; unknown keys → **`400`** **`invalid_input`**. **`404`** **`section_not_found`** when **`sectionId`** is not a section row for this document. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listSectionElements
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: sectionId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Optional element **`type`** filter (comma-separated), same values as **GET …/elements**.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (element **`id`** UUID).
      responses:
        "200":
          description: Elements page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedElements" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`section_not_found`**.'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`section_elements_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/relationships:
    get:
      summary: List relationships in a document
      description: |
        Lists **`document_relationships`**: directed edges between **elements** (and sometimes document-scoped anchors) such as caption ↔ figure,
        parent ↔ child, reading-order **precedes**, or reference links extracted by parsers.

        Filter with **`type`** (comma-separated) when you only want one family of edges for graph visualization or QA rules.
        Results are paginated (**`limit`** / **`cursor`**); large contracts may require multiple pages.

        This endpoint is authoritative for **within-document** relationship
        inspection and debugging.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`type`**, **`limit`**, **`cursor`** only; unknown keys → **`400`** **`invalid_input`**. **`nextCursor`** is the last **`document_relationships.id`** UUID on the page. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listDocumentRelationships
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Optional relationship type filter (comma-separated).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (relationship row **`id`** UUID).
      responses:
        "200":
          description: Relationships page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedRelationships" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`relationships_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/elements/{elementId}/relationships:
    get:
      summary: List relationships for an element
      description: |
        Lists **all relationship records touching one `elementId`** (incoming and outgoing), optionally filtered by **`type`**.

        Ideal for “what does this table cite?” or “which caption points here?” without traversing the entire **…/relationships** feed.

        Pagination matches the document-level relationships API; combine with **GET …/elements/{elementId}** for payload context.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`type`**, **`limit`**, **`cursor`** only; unknown keys → **`400`** **`invalid_input`**. **`nextCursor`** is the last **`document_relationships.id`** UUID on the page. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listElementRelationships
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: elementId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Optional relationship type filter (comma-separated).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (relationship row **`id`** UUID).
      responses:
        "200":
          description: Relationships page (edges touching **`elementId`**).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedRelationships" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`element_relationships_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/elements/{elementId}/related:
    get:
      summary: List related elements for an element
      description: |
        Returns **neighbor elements** derived from stored relationships—ready-to-render peers adjacent in layout, reading order, or semantics.

        Unlike **…/relationships** (edges), this endpoint resolves to **`Element`** payloads so assistants can quote neighboring paragraphs,
        captions, or callouts in one step; use **`type`** to narrow which adjacency rules apply.


        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`type`**, **`limit`**, **`cursor`** only; unknown keys → **`400`** **`invalid_input`**. Pagination follows **relationship** rows: **`cursor`** / **`nextCursor`** are **`document_relationships.id`** UUIDs (not element ids). After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: listRelatedElements
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: elementId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Optional relationship type filter (comma-separated).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Prior page **`nextCursor`** (**`document_relationships.id`** UUID).
      responses:
        "200":
          description: Related elements page (**`items`** are **`Element`** payloads for neighbors on this page of edges).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedElements" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`related_elements_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/artifacts:
    get:
      summary: List processing artifacts for a document
      description: |
        Lists **artifacts** stored for a document after conversion—for example raw parser JSON, page images, extracted pictures,
        audio transcripts, and other binary or large outputs that are not first-class **elements**.

        Each item includes **`kind`**, size, and timestamps. When present, **`contentUrl`** is a **short-lived HTTPS URL** for direct download.

        Use **`kind`** (comma-separated filter) to narrow results—for example a **raw JSON parse artifact** or a **page image**.

        To list the same artifacts **by processing job**, call **GET …/jobs/{jobId}/artifacts**. Use **this** route when you already have **`documentId`**
        and want everything attached to that file.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`kind`**, **`limit`**, **`cursor`** only; unknown keys → **`400`** **`invalid_input`**. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        **Required scope:** **`docs:parsed:read`**.
      operationId: listDocumentArtifacts
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kind
          in: query
          required: false
          schema: { type: string }
          description: Optional artifact kind filter (comma-separated).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            Pagination: **`id`** (UUID) of the last artifact from the previous page—use the prior response’s **`nextCursor`** verbatim.
            Omit on the first request.
      responses:
        "200":
          description: Artifacts page (**`contentUrl`** on items when a signed download is available).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedArtifacts" }
        "400":
          description: Malformed path UUIDs, unknown query keys, **`limit`** out of range, or non-UUID **`cursor`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** or **`document_not_found`** (including soft-deleted documents).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`artifacts_list_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/artifacts/{artifactId}:
    get:
      summary: Get artifact metadata
      description: |
        Returns **one artifact row** by **`artifactId`**: MIME type, **`kind`**, byte size, checksums when present, and provenance back to the job
        that produced it.

        **`contentUrl`**, when returned, is a **signed HTTPS URL**—it expires; clients should fetch immediately or persist the blob in their own
        object storage if long-lived access is required. Do not log query strings from these URLs in plain text.

        Combine with **GET …/artifacts** listing to resolve human-friendly labels; this GET is the supported way to refresh a near-expired download
        link without re-running ingestion.

        Path **`projectId`** must match the token’s project (**`sub`**). No query parameters (strict empty query). After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires scope **`docs:parsed:read`**.
      operationId: getDocumentArtifact
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: artifactId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Artifact metadata (**`storageRef`** and optional signed **`contentUrl`**).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Artifact" }
        "400":
          description: Malformed path UUIDs or unknown query keys (**none** are defined for this route).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`docs:parsed:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`artifact_not_found`**.'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`artifact_get_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /projects/{projectId}/kbs/{kbId}/documents/{documentId}/artifacts/{artifactId}/content:
    get:
      summary: Download artifact binary
      description: |
        Streams the artifact's binary body when the platform exposes a **download** for it—the same cases where
        **GET …/artifacts** and **GET …/artifacts/{artifactId}** return a non-null **`contentUrl`** and **`status`**
        is **`ready`**. Some kinds (for example the raw JSON parse artifact) are metadata-only and never stream bytes from this route.

        **Authentication (either):**
        - **`token`** query parameter — short-lived signed JWT parsed from **`contentUrl`** on **GET …/artifacts** and **GET …/artifacts/{artifactId}** (suitable for `<img src>` without a Bearer header).
        - **`Authorization: Bearer`** project access token with scope **`docs:parsed:read`** (same as artifact metadata routes).

        Returns **`422`** **`artifact_not_streamable`** when this artifact has no downloadable binary or is not **ready** (e.g. metadata-only kinds). **`Content-Type`** reflects **`mimeType`** when present.

        Path UUIDs must be valid; signed **`token`** claims must match the path (**`projectId`**, **`kbId`**, **`documentId`**, **`artifactId`**). Unknown query keys (other than **`token`**) → **`400`** **`invalid_input`**. After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: getDocumentArtifactContent
      tags: [Structure]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: documentId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: artifactId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: |
            Signed download token from **`contentUrl`**. When omitted, send **`Authorization: Bearer`** with **`docs:parsed:read`**.
      security:
        - {}
        - projectBearerAuth: []
      responses:
        "200":
          description: Binary body
          headers:
            Content-Type:
              schema: { type: string }
            Content-Length:
              schema: { type: integer }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
            image/png:
              schema: { type: string, format: binary }
            image/jpeg:
              schema: { type: string, format: binary }
            image/webp:
              schema: { type: string, format: binary }
        "400":
          description: Malformed path UUIDs or unknown query keys (only **`token`** is defined).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: 'Missing/invalid **`Authorization: Bearer`**, or missing/invalid/expired **`token`** (signed download JWT).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope on Bearer path only (`docs:parsed:read`); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`**, **`document_not_found`**, or **`artifact_not_found`**.'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: No blob to stream or artifact not **`ready`** (**`artifact_not_streamable`**) or blob fetch rejected (**`invalid_blob_url`**, **`blob_download_failed`**, etc. per **`Problem.code`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: Unexpected failure (**`artifact_content_failed`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # --------------------------------------
  # Jobs (PROJECT TOKEN ONLY)
  # --------------------------------------
  /projects/{projectId}/kbs/{kbId}/jobs:
    get:
      summary: List jobs in a knowledge base
      description: |
        Use this to monitor ingest progress, find failed jobs for retry, or inspect document ingest jobs. Filter by **`status`**
        (pending, parsing, chunking, indexing, completed, failed, canceled) and paginate with **`limit`** and **`cursor`**.
        Sorting is **`createdAt` descending** with **`id` descending** as a stable tie-breaker; **`nextCursor`** is the last job’s **`id`** (UUID)—pass it back **verbatim** with the **same `status` filter** as the page that produced it.
        Malformed query values, **`limit`** out of range, a non-UUID **`cursor`**, or a **`cursor`** that does not match a visible job for this KB/project (or filtered **status**) → **`400`** (**`invalid_input`** or **`invalid_cursor`**).
        After a successful rate-limit check, **non-429** responses may still include **`X-RateLimit-*`** headers.
        Requires project access token.
      operationId: listJobs
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              [
                pending,
                parsing,
                chunking,
                indexing,
                completed,
                failed,
                canceled,
              ]
          description: |
            Filter jobs by processing status.
            
            Job lifecycle stages:
            - `pending`: Job is queued and waiting to start processing
            - `parsing`: Document is being parsed (extracting text, structure, tables, etc.)
            - `chunking`: Parsed content is being split into chunks
            - `indexing`: Chunks are embedded and indexed for **`POST …/search`**
            - `completed`: Job finished successfully - document is fully indexed and searchable
            - `failed`: Job failed at some stage after exhausting retries
            - `canceled`: Job was manually canceled before completion
            
            Omit this parameter to retrieve jobs with all statuses.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: Maximum jobs per page (default 20, max 100).
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            Opaque pagination cursor: the **`nextCursor`** value from the previous page (a job **`id`**). Must be a UUID and must refer to a job returned under the same **`kbId`**, **`projectId`**, and **`status`** filter; otherwise **`400`** (**`invalid_cursor`**).
      responses:
        "200":
          description: Jobs page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedJobs" }
        "400":
          description: |
            Invalid path or query parameters (**`limit`** / **`status`** out of range, malformed **`cursor`**, duplicate query keys), or **`invalid_cursor`** when the cursor is not a job in this KB/project under the current filter.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`jobs:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base not found (or not in this project)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while listing jobs.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/jobs/{jobId}:
    get:
      summary: Get job status
      description: |
        Returns one **async job** by **`jobId`** (see **`AsyncJob`** schema).


      operationId: getJob
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Job
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncJob" }
        "400":
          description: |
            **`projectId`**, **`kbId`**, or **`jobId`** is not a valid UUID or failed path validation (**`invalid_input`**).
            (A syntactically valid **`projectId`** that does not match the bearer token’s project still yields **`401`**, not **`400`**.)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`jobs:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base or job not found (or job not in this KB/project)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while loading the job.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/jobs/{jobId}/artifacts:
    get:
      summary: List artifacts produced by a job
      description: |
        Lists **artifacts produced during a specific job** (same **`Artifact`** JSON shape as **GET …/documents/{documentId}/artifacts**: **`status`**, **`mimeType`**, **`byteSize`**, **`storageRef`**, optional signed **`contentUrl`** when **`status`** is **`ready`** and a binary exists).

        Use this when you have **`jobId`** from upload, reprocess, or webhooks—for example to inspect outputs after a failure, compare runs, or download binaries via **`contentUrl`** (then **GET …/artifacts/{artifactId}/content** with Bearer or **`token`**).

        **GET …/documents/{documentId}/artifacts`** lists artifacts for a document and requires **`docs:parsed:read`**. **This** route requires **`jobs:read`**.

        Path **`projectId`** must match the token’s project (**`sub`**). Query keys **`kind`**, **`limit`**, **`cursor`** only (same semantics as the document listing: comma-separated **`kind`**, **`limit`** 1–200 default 50, **`cursor`** opaque from **`nextCursor`**); unknown keys → **`400`** **`invalid_input`**.

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: listJobArtifacts
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kind
          in: query
          required: false
          schema: { type: string }
          description: |
            Optional artifact **kind** filter: comma-separated list (e.g. `image,pdf_page`). Rows must match one of the
            listed kinds (exact string; no wildcards).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: |
            **Opaque** continuation token from the prior response’s **`nextCursor`**. Omit on the first request; pass the value back **unchanged** on the next (do not parse or construct).
      responses:
        "200":
          description: Artifacts page
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedJobArtifacts" }
        "400":
          description: |
            Malformed path UUIDs; unknown query keys; **`limit`** outside 1–200; or opaque **`cursor`** that cannot be decoded (**`invalid_cursor`** — pass **`nextCursor`** from the prior response unchanged).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Missing or invalid bearer token, or path **`projectId`** does not match the token’s project.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`jobs:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: '**`kb_not_found`** (KB not in this project or missing), or **`job_not_found`** (no job with this **`jobId`** for this KB/project).'
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected failure (**`job_artifacts_list_failed`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/jobs/{jobId}/cancel:
    post:
      summary: Cancel a running job
      description: |
        Cancel a job that is **not** in a terminal state (`completed`, `failed`, `canceled`). Applies to **any**

        **No-op** if the job is already terminal: returns **200** with the current job body and does **not** enqueue a **`job.canceled`** webhook (avoid duplicate deliveries on idempotent cancel).

        When this call **does** transition the job to **`canceled`**, a subscribed active webhook may receive **`job.canceled`**; enqueueing that delivery is **best-effort**—if delivery persistence fails after the job is already **`canceled`**, the API still returns **200** with the updated job.

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.

        Requires project access token with scope **`jobs:cancel`**.
      operationId: cancelJob
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Canceled (or noop if terminal)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncJob" }
        "400":
          description: |
            **`projectId`**, **`kbId`**, or **`jobId`** is not a valid UUID (**`invalid_input`**). Valid UUID path with wrong project token → **`401`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`jobs:cancel` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Not found
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while canceling the job.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/jobs/{jobId}/retry:
    post:
      summary: Retry one failed or canceled job
      description: |
        Re-queue a **single** failed or canceled job (identified by `jobId`) for another attempt.
        Use this after fixing transient issues (e.g. network, upstream service) or re-running
        after cancel. The job must belong to the given knowledge base and project.

        For **all** failed or canceled jobs in the same KB (no `jobId` in the path), use
        `POST /projects/{projectId}/kbs/{kbId}/jobs/retry`.

        Returns `202` with the updated job; poll job status to track progress. After a successful rate-limit check,
        **non-429** responses may include **`X-RateLimit-*`** headers. Requires project access token with scope
        `jobs:retry`.
      operationId: retryJob
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project that owns the knowledge base and job.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base that contains the job.
          schema: { type: string, format: uuid }
        - name: jobId
          in: path
          required: true
          description: Job to retry; must be in status `failed` or `canceled`.
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Retry scheduled for the requested job
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncJob" }
        "401":
          description: Missing or invalid project access token
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Token does not include scope `jobs:retry`; **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base or job not found, or job does not belong to this KB/project
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "400":
          description: |
            **`projectId`**, **`kbId`**, or **`jobId`** is not a valid UUID (**`invalid_input`**), or the job cannot be retried (e.g. still **`pending`** / in progress — only **`failed`** and **`canceled`** are retryable; **`code`** **`job_not_retryable`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited; check `Retry-After` and rate-limit headers
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while retrying the job.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/jobs/retry:
    post:
      summary: Retry all failed or canceled jobs in a knowledge base
      description: |
        Re-queues **every** job in the given knowledge base whose status is `failed` or
        `canceled`. Omitting `jobId` from the path (compared to the single-job retry endpoint)
        selects this bulk behavior. Jobs in other statuses are unchanged.

        Returns `202` with the list of jobs that were reset for retry (may be empty if none
        matched). Each listed job is returned in the same shape as a single-job retry response.
        All matching jobs are updated **atomically** per request (no partial batch if a later update fails). Work is still
        picked up asynchronously by background workers—this endpoint does not block until processing finishes.
        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
        Requires project access token with scope `jobs:retry`.
      operationId: retryFailedJobsInKb
      tags: [Jobs]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project that owns the knowledge base.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base whose failed or canceled jobs should be retried.
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Retry scheduled for all matching jobs in the KB
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RetryJobsAccepted" }
        "400":
          description: |
            **`projectId`** or **`kbId`** is not a valid UUID (**`invalid_input`**). (A syntactically valid **`projectId`**
            that does not match the bearer token’s project still yields **`401`**, not **`400`**.)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`jobs:retry` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Knowledge base not found for this project
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while retrying jobs in the knowledge base.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  # --------------------------------------
  # --------------------------------------
  /projects/{projectId}/kbs/{kbId}/search:
    post:
      summary: Search documents
      description: |
        Semantic or hybrid search over indexed content in a knowledge base. You send a natural-language **`query`**;
        the service scores stored chunks and returns the best matches for your **`limit`**. Requires scope **`search:run`**.

        **How matching works**
        - **Semantic (default):** dense vector similarity against embeddings for this knowledge base.
        - **Hybrid:** combines vector similarity with keyword (full-text) retrieval, then fuses ranked lists.
          Set **`settings.searchMode`** to **`hybrid`** when you want both signals.
        - **Similarity floor:** vector matches must meet a fixed similarity floor (**`0.5`**) on each request; this is not configurable in the request body.

        **Reranking (optional):** turn on **`settings.rerank.isEnabled`** to run a second-stage rerank over a wider candidate pool (**`settings.retrieveTopK`**). The post-rerank count is capped by **`settings.rerank.topK`** (defaults to **5** when omitted, and never exceeds **`limit`**). Provider and model are chosen from the **knowledge base’s search configuration** (set when you create or update the KB), not from the search request. If the rerank step cannot be completed, the call fails with **`502`** **`rerank_failed`**.

        **Citations and layout**
        - Set **`settings.includeSourceMetadata: true`** so each hit can include **`chunkId`**, **`chunkIndex`**, and **`metadata`** (stable **`citationRef`**, original filename, headings, MIME type, labels, and similar fields when available).
        - Set **`settings.includeGrounding: true`** to ask for layout-style **`grounding`** (**`pages`**, bbox, section path, and related fields when stored). What appears may still respect **knowledge base** grounding visibility defaults.


        **Response:** **`application/json`** with **`results`**, **`nextCursor`**, and **`format`**. If **`settings.diagnostics`** is **`true`**, the response also includes **`SearchDiagnostics`** (counts, timing, effective modes—**never** the raw query text).

        **`format` query parameter:** the only supported query key on this route (extras → **`400`** **`invalid_input`**). Chooses how each hit’s **`content`** is built (**`markdown`**, **`html`**, **`text`**, **`json`**, **`doctags`**). Default **`markdown`**. The value must be one of the parse output formats enabled for that knowledge base; otherwise **422** **`format_not_available`**.

        Path **`projectId`** must match the token’s project. Successful responses may include **`X-RateLimit-*`** headers when rate limiting is enabled.
      operationId: search
      tags: [Search]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [markdown, html, text, json, doctags]
            default: markdown
          description: |
            How each search hit's `content` is derived from stored chunk fields. The HTTP response
            remains JSON (`results` + pagination).
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            default: application/json
          description: |
            Response is always **`application/json`**. The server does not require this header;
            include `Accept: application/json` if your client sends `Accept` by default.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
            examples:
              fullSettings:
                summary: Typical settings block (hybrid + rerank + filters)
                description: |
                  Shows how **`settings`** fields combine at the same level as **`rerank`**. Omit **`tableQuery`** when you do not
                  need table substring filtering; when you include it, **`cellContains`** must be non-empty (example uses **`policy`**).
                value:
                  query: "example query"
                  limit: 10
                  settings:
                    rerank:
                      isEnabled: "yes"
                      topK: 5
                    retrieveTopK: 50
                    searchMode: hybrid
                    includeSourceMetadata: false
                    includeGrounding: false
                    diagnostics: false
                    groupBy: document
                    elementTypes: []
                    tableQuery:
                      mode: cell_contains
                      cellContains: "policy"
              minimal:
                summary: Query only (defaults everywhere)
                value:
                  query: "refund policy"
      responses:
        "200":
          description: |
            Search results: **`results`**, **`nextCursor`**, **`format`**. When the request set
            **`settings.diagnostics`: true**, **`diagnostics`** (**`SearchDiagnostics`**) is included.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
              examples:
                withCitations:
                  summary: Results with includeSourceMetadata
                  description: |
                    Request body included `"settings": { "includeSourceMetadata": true }`. Each hit includes
                    chunkId, chunkIndex, and metadata (filename + citationRef plus doc/source attributes).
                  value:
                    format: markdown
                    nextCursor: null
                    results:
                      - documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        filename: "handbook.pdf"
                        content: "## Paid time off\n\nFull-time employees receive..."
                        score: 0.812
                        chunkId: "550e8400-e29b-41d4-a716-446655440000"
                        chunkIndex: 3
                        metadata:
                          filename: "handbook.pdf"
                          citationRef: "a1b2c3d4e5f6789012345678"
                          heading: "Paid time off"
                          sourceUri: "s3://example-bucket/handbook.pdf"
                          mimetype: "application/pdf"
                basic:
                  summary: Results without citation fields
                  value:
                    format: markdown
                    nextCursor: null
                    results:
                      - documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        filename: "handbook.pdf"
                        content: "## Paid time off\n\nFull-time employees receive..."
                        score: 0.812
                withDiagnostics:
                  summary: Request with settings.diagnostics true
                  description: |
                    Request included `"settings": { "diagnostics": true, "includeSourceMetadata": true }`.
                    Response adds **diagnostics** with retrieval stats (no query echo).
                  value:
                    format: markdown
                    nextCursor: null
                    results:
                      - documentId: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
                        filename: "handbook.pdf"
                        content: "## Paid time off\n\nFull-time employees receive..."
                        score: 0.812
                        chunkId: "550e8400-e29b-41d4-a716-446655440000"
                        chunkIndex: 3
                        metadata:
                          filename: "handbook.pdf"
                          citationRef: "a1b2c3d4e5f6789012345678"
                    diagnostics:
                      searchMode: hybrid
                      rerank: true
                      retrieveTopK: 50
                      rerankTopK: 8
                      limit: 10
                      scoreThreshold: 0.5
                      mergedCandidateCount: 42
                      chunkCandidateCount: 38
                      tableRowCandidateCount: 3
                      figureCandidateCount: 1
                      rerankResultCount: 42
                      resultCount: 8
                      retrievalModes: [chunk]
                      durationMs: 187
                      emptyQueryEmbedding: false
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`search:run` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            **`kb_not_found`** when the knowledge base does not exist for this project (or is not visible to the caller).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "400":
          description: |
            Invalid **path** UUIDs (**`invalid_input`**), unknown or duplicate **query** keys (**`invalid_input`** — only **`format`** is allowed), invalid **`format`** enum, **malformed JSON** body (**`invalid_input`**), or JSON body that fails
            **SearchRequest** schema validation (e.g. missing **`query`**, **`limit`** out of range). Does **not** include
            invalid search **`nextCursor`** or embedding-dimension issues — those use **422** (see below).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                invalidBody:
                  summary: Request body does not match SearchRequest
                  value:
                    title: "Bad Request"
                    status: 400
                    detail: "query: Required"
        "422":
          description: |
            Request is well-formed but cannot be processed for this KB or search pass. Common **`code`** values:

            - **`format_not_available`**: the **`format`** query is not in this KB’s **`parse.outputFormats`**.
            - **`invalid_input`**: invalid **`nextCursor`** for pagination (e.g. not a non-negative integer string where required).
            - **`invalid_embedding_dimensions`**: query embedding width does not match the stored vector column (configuration mismatch).
            - **`embedding_error`**: embedding call failed in a non-retryable way (unsupported/misconfigured model, bad provider response shape, etc.).

            Clients should use the problem **`code`** (and **`detail`**) to branch; do not rely on **400** for these cases.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                formatNotAvailable:
                  summary: Requested format not available
                  value:
                    title: "Unprocessable Entity"
                    status: 422
                    detail: 'Documents in this knowledge base were not processed in the requested format "json". Available formats: markdown.'
                    code: "format_not_available"
                invalidNextCursor:
                  summary: Invalid nextCursor for search pagination
                  value:
                    title: "Unprocessable Entity"
                    status: 422
                    detail: "Invalid nextCursor"
                    code: "invalid_input"
                embeddingError:
                  summary: Embedding request not retryable
                  value:
                    title: "Unprocessable Entity"
                    status: 422
                    detail: "Unsupported embedding model: example:unknown-model"
                    code: "embedding_error"
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "502":
          description: |
            Reranking failed. This only occurs when reranking was requested and the configured
            rerank provider/model could not be called (e.g. missing API key, upstream error).
            The response does not include provider details.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                rerankFailed:
                  summary: Rerank failed
                  value:
                    title: "Bad Gateway"
                    status: 502
                    detail: "Rerank failed"
                    code: "rerank_failed"
        "503":
          description: Service temporarily unavailable (**`service_unavailable`**).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          description: |
            Unexpected server error while executing search (**`search_failed`** or other internal **`code`**). Misconfigured embeddings or invalid request shape usually produce **422** with **`embedding_error`**.
            When **`settings.rerank`** is **true**, reranker or upstream failures typically surface as **502** (**`rerank_failed`**), not **500**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/webhook:
    get:
      summary: Get the single webhook configured for this KB
      description: |
        Fetch the webhook configuration for this KB, if any. Each KB has at most one
        webhook. Returns 404 when the KB does not exist for this project (**`kb_not_found`**) or when
        the KB exists but no webhook is configured (**`webhook_not_found`**). The response body matches
        **`Webhook`** (URL, events, active, retry policy, etc.); the signing **secret is never returned** on GET—use
        **PUT** / **PATCH** when creating or rotating secrets. After a successful rate-limit check, **non-429**
        responses may include **`X-RateLimit-*`** headers. Requires project access token with scope **`webhooks:read`**.
      operationId: getWebhook
      tags: [Webhook]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Webhook
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400":
          description: |
            **`projectId`** or **`kbId`** is not a valid UUID (**`invalid_input`**). (A syntactically valid **`projectId`**
            that does not match the bearer token’s project still yields **`401`**, not **`400`**.)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`webhooks:read` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Not found. Problem **`code`** is **`kb_not_found`** when the KB does not exist for this project, or
            **`webhook_not_found`** when the KB exists but no webhook has been configured.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while loading the webhook configuration.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

    put:
      summary: Create or replace the webhook for this KB (single webhook only)
      description: |
        Create or replace the single webhook for this KB. Provide the URL that will
        Omit **`events`** to default to the **full** event catalog. The **webhook secret is
        returned only on create/replace**—store it securely; your endpoint must verify
        the `X-Indexify-Signature` header. Requires scope `webhooks:write`.

        Returns **`201`** when no webhook row existed for this KB before the request, and **`200`** when replacing
        an existing row (status selection is based on presence of a row for **`kbId`**, not on request body equality).

        Optional **`Idempotency-Key`**: when set, duplicate PUTs with the same key (scoped to **project** + **KB**)
        within the cache window replay the prior **JSON** response and status (**`200`** or **`201`**) without re-running side effects.

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: upsertWebhook
      tags: [Webhook]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Idempotency key to prevent duplicate operations.
            Provide a unique string value (e.g., UUID) to ensure the same request
            is not processed multiple times if retried.
            
            When provided, the API will return the same response for duplicate requests
            within a time window (typically 24 hours). Keys are scoped per **project** and **knowledge base**
            so the same header value on a different KB does not collide.
            
            Recommended for create operations to handle network retries safely.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpsertRequest" }
      responses:
        "200":
          description: Replaced existing webhook
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookWithSecret" }
        "201":
          description: Created new webhook
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookWithSecret" }
        "400":
          description: |
            Bad request — typically **`invalid_input`**: malformed JSON, invalid path UUIDs, **`WebhookUpsertRequest`**
            validation (e.g. invalid **`url`**, unknown **`events`** values, empty **`events`**), or retry policy constraint violated
            (`(maxAttempts - 1) * backoffSeconds` must not exceed 300).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`webhooks:write` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Knowledge base not found for this project (problem **`code`**: **`kb_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while creating or replacing the webhook.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

    patch:
      summary: Partially update the existing webhook
      description: |
        Partially update the KB's webhook. Change URL, events, active flag, retry policy,
        or rotate the secret. Only include fields you want to update. The plaintext **`secret`**
        is returned only when you rotate it (`secret` field present in the body); otherwise the response matches
        **`Webhook`** without a **`secret`** field. An empty JSON object **`{}`** is accepted and returns **200**
        with the current configuration (no persisted changes). Requires scope `webhooks:write`. Returns **404** if
        no webhook is configured.

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: updateWebhook
      tags: [Webhook]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpdateRequest" }
            example: { "active": false }
      responses:
        "200":
          description: Updated (includes **`secret`** only when the secret was rotated in this request)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/WebhookWithSecret"
                  - $ref: "#/components/schemas/Webhook"
        "400":
          description: |
            Bad request — typically **`invalid_input`**: malformed JSON, invalid path UUIDs, or **`WebhookUpdateRequest`**
            validation (e.g. invalid **`url`**, unknown **`events`** values, or retry policy: `(maxAttempts - 1) * backoffSeconds` must not exceed 300).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`webhooks:write` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            Not found — **`kb_not_found`** if the KB is not in this project, or **`webhook_not_found`** if no webhook
            exists yet for this KB (create one with **PUT** `.../webhook` first).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while updating the webhook.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

    delete:
      summary: Delete the webhook for this KB
      description: |
        Remove the webhook for this KB. Job events will no longer be delivered. Use this
        when disabling integrations or before changing to a new URL (create with PUT
        after delete). Requires scope `webhooks:delete`. Returns **204** with an empty body on success, or **404**
        when the KB is missing (**`kb_not_found`**) or no webhook row exists (**`webhook_not_found`**).

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: deleteWebhook
      tags: [Webhook]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: Deleted
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "400":
          description: |
            **`projectId`** or **`kbId`** is not a valid UUID (**`invalid_input`**). (A syntactically valid **`projectId`**
            that does not match the bearer token’s project still yields **`401`**, not **`400`**.)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`webhooks:delete` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            No webhook to delete, or KB not found for this project (problem **`code`**: **`webhook_not_found`** or **`kb_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while deleting the webhook.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /projects/{projectId}/kbs/{kbId}/webhook/test:
    post:
      summary: Send a test delivery to the KB's webhook URL
      description: |
        Sends a test payload (event `webhook.test`) to the KB's webhook URL. Use this to
        verify your endpoint receives and verifies requests (e.g. HMAC signature) before
        relying on real job events. The payload is WebhookDelivery-shaped with placeholder
        project, knowledgebase, document, and job data so you can validate parsing. The
        payload includes a top-level `testDelivery: true` field so your endpoint can
        identify and ignore or handle test deliveries. The API **POSTs synchronously** to your URL; on success it returns
        **202** with a **`deliveryId`** (UUID used in the test payload). Returns **502** with **`code`** **`webhook_delivery_failed`** if the delivery fails (non-2xx,
        network error, or webhook **inactive** / missing row when loading delivery context). Requires scope **`webhooks:test`**. Returns **404** (**`kb_not_found`** / **`webhook_not_found`**) if the KB is missing or no webhook is configured.

        After a successful rate-limit check, **non-429** responses may include **`X-RateLimit-*`** headers.
      operationId: testWebhook
      tags: [Webhook]
      parameters:
        - name: projectId
          in: path
          required: true
          description: Project identifier.
          schema: { type: string, format: uuid }
        - name: kbId
          in: path
          required: true
          description: Knowledge base identifier.
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Test delivery succeeded (your endpoint returned 2xx)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryIdResponse" }
        "400":
          description: |
            **`projectId`** or **`kbId`** is not a valid UUID (**`invalid_input`**). (A syntactically valid **`projectId`**
            that does not match the bearer token’s project still yields **`401`**, not **`400`**.)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Unauthorized
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Insufficient scope (`webhooks:test` required); **`code`** may be **`insufficient_scope`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: |
            No webhook configured, or KB not found for this project (**`kb_not_found`** or **`webhook_not_found`**).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "502":
          description: |
            Test delivery failed — your webhook URL returned non-**2xx**, the request failed at the network layer, or the
            webhook is **inactive** / missing when preparing the outbound POST. **`code`** **`webhook_delivery_failed`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: Service temporarily unavailable (for example temporary backend issues); **`code`** **`service_unavailable`**.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: Unexpected server error while preparing the test delivery.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  # ---------------------------
  # Health (public)
  # ---------------------------
  /health:
    get:
      summary: Health check with dependency status
      description: |
        Public liveness and readiness probe. Returns **`200`** with a JSON body when the API can serve traffic (**`status`**
        may still be **`degraded`** if a non-critical dependency is unhealthy). Returns **`503`** when the deployment considers
        itself unable to reliably serve requests—pause traffic or fail over per your runbook.

        **Body:** See **`HealthResponse`**. Each dependency returns only **`status`** (`ok` | `degraded` | `down`)—no error message text.

        Some environments return **`200`** with **`degraded`** when non-critical dependencies are unhealthy. **`503`** means the deployment
        considers itself unable to serve traffic reliably.

        No authentication required; rate limits apply per client IP (derived from **`X-Forwarded-For`**, **`X-Real-IP`**, or a synthetic key when absent).

        **Query parameters:** none are read; clients may append ignored query strings for cache-busting without changing behavior.

        After a successful rate-limit check, **non-429** responses include **`X-RateLimit-*`** headers on **200**, **503**, and **500**.
      operationId: health
      tags: [Health]
      security: []
      responses:
        "200":
          description: Service is healthy or degraded
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
        "503":
          description: Service is down or critical systems unavailable
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
        "500":
          description: |
            Unexpected failure while building the health snapshot (rare; dependency probes normally return **`down`** in-body instead of throwing).
            **`code`** may be **`health_check_failed`**.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Rate limited
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Retry-After: { $ref: "#/components/headers/Retry-After" }
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

webhooks:
  webhookDeliveries:
    post:
      summary: Outbound KB webhook deliveries
      description: |
        Consumer contract for the URL you configure on **`PUT .../webhook`**: the platform sends **`POST`** requests with a
        JSON body for each subscribed event. Always verify **`X-Indexify-Signature`** using the **`secret`** returned when you
        created or last rotated the webhook (see **Signature** below).

        **Document pipeline events** (`job.*`):
        - `job.pending` — ingest job row queued by the API (**upload**, **reprocess**, **replace**, **retry**); **`job.status`** is **`pending`** and **`startedAt`** is typically **`null`** until **`job.started`**.
        - `job.started` — job moves from pending to active processing.
        - `job.parsing_started`, `job.chunking_started`, `job.indexing_started` — stage entry (before parser / embedding work for that stage).
        - `job.parsing_format_completed` — one configured parse output format stored; **`data.parse`**: **`format`**, **`ordinal`**, **`total`**.
        - `job.parsing_structure_ready` — multimodal structure row updated after JSON parse; **`data.parse`**: **`structureReady`**.
        - `job.parsing_partial_failure` / `job.chunking_partial_failure` — some paths failed while the job continued; **`data.parse`** / **`data.chunking`**: **`succeeded`**, **`failed`**.
        - `job.chunking_format_selected` — chunk source chosen (`original_file` or a parsed format); **`data.chunking`**: **`source`**.
        - `job.parsing_completed`, `job.chunking_completed`, `job.indexing_completed` — stage boundaries before terminal events.
        - `job.completed` — document fully indexed.
        - `job.failed` — terminal failure after retries.
        - `job.canceled` — canceled via API.

        these are the webhook signals for that flow (**not** necessarily `job.completed` / `job.failed` for the same **`jobId`**—see **`WebhookDelivery`**).
        Payload shape matches ingest jobs, but **`data.document`** is a **placeholder** (**empty `id` / `filename` / `contentType`**, **`size: 0`**, **`uploadedAt`** ≈ delivery time) because the job has **`documentId` null**:

        **Headers**
        - `Content-Type: application/json`
        - `X-Indexify-Signature` — see **Signature**.

        **Signature (`X-Indexify-Signature`)**
        1. Read the **raw HTTP body** as a byte sequence exactly as received (the UTF-8 encoding of the JSON payload the platform sent).
        2. Compute **`HMAC-SHA256(webhook_secret, raw_body)`** and encode the digest as a **lowercase hexadecimal string** (64 chars).
        3. Compare that string to the header value using a **constant-time** comparison (e.g. `crypto.timingSafeEqual` in Node).
        There is **no** `sha256=` prefix or alternate encodings—the header value **is** the hex digest. If a secret is configured
        and the header is missing or mismatched, reject the request (**401**).

        **Idempotency**
        Use **`deliveryId`**: each HTTP delivery attempt has its own id; retries may use a **new** `deliveryId`. De-duplicate
        using your own store keyed by **`deliveryId`** (see **`DeliveryIdResponse`** / **`WebhookDelivery`**).

        **Retry policy**
        On non-2xx or timeout, the platform retries up to **`retryPolicy.maxAttempts`** (1–10) with a **fixed** delay of
        **`retryPolicy.backoffSeconds`** (1–300) between attempts. The API enforces **`(maxAttempts - 1) * backoffSeconds <= 300`**
        — adjust both fields together if validation fails.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDelivery"
            examples:
              jobPending:
                summary: Job Pending Event
                description: |
                  Fired when an ingest job row is queued: after **upload**, **replace**, **reprocess**, or **retry** (`POST …/jobs/{jobId}/retry`, **`POST …/jobs/retry`**). **`job.status`** is **`pending`**; **`startedAt`** is **`null`** until processing begins. **`estimatedDurationMs`** is **`null`** (same as **`job.started`**).
                value:
                  event: job.pending
                  timestamp: "2026-02-01T14:30:12.800Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "2b3c4d5e-6f70-8192-3456-7890abcdef01"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: pending
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:12.500Z"
                      startedAt: null
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      estimatedDurationMs: null
                      stages: null
                      error: null

              jobStarted:
                summary: Job Started Event
                description: |
                  Fired when a job transitions from pending to active processing. **`estimatedDurationMs`** is **`null`** today (reserved); **`durationMs`** is omitted until a terminal **`completedAt`**, **`failedAt`**, or **`canceledAt`** exists.
                value:
                  event: job.started
                  timestamp: "2026-02-01T14:30:15.234Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "1a2b3c4d-5e6f-7890-1234-567890abcdef"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: parsing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:15.234Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      estimatedDurationMs: null
                      stages:
                        parsing:
                          status: pending
                          durationMs: 0
                          retriesAttempted: 0
                          chunksCreated: 0
                          vectorsCreated: 0
                        chunking:
                          status: pending
                          durationMs: 0
                          retriesAttempted: 0
                          chunksCreated: 0
                          vectorsCreated: 0
                        indexing:
                          status: pending
                          durationMs: 0
                          retriesAttempted: 0
                          chunksCreated: 0
                          vectorsCreated: 0
                      error: null

              jobCompleted:
                summary: Job Completed Event
                description: Fired when a job finishes successfully (document fully indexed)
                value:
                  event: job.completed
                  timestamp: "2026-02-01T14:32:45.123Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: completed
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:32:45.123Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: "2026-02-01T14:32:45.100Z"
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      durationMs: 149866
                      stages:
                        parsing:
                          status: completed
                          durationMs: 45200
                        chunking:
                          status: completed
                          durationMs: 12400
                          chunksCreated: 87
                        indexing:
                          status: completed
                          durationMs: 92266
                          vectorsCreated: 87
                      error: null

              jobFailed:
                summary: Job Failed Event
                description: Fired when a job fails at any stage after exhausting retries
                value:
                  event: job.failed
                  timestamp: "2026-02-01T14:35:22.789Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "8d0f7680-8536-51ef-a827-f18ad2fa1bf8"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-2f1e3c5b-9876-4321-dcba-876543210fed"
                      filename: "corrupted-file.pdf"
                      contentType: "application/pdf"
                      size: 5894200
                      uploadedAt: "2026-02-01T14:33:45.123Z"
                    job:
                      id: "job-1a2b3c4d-5e6f-7890-1234-567890abcdef"
                      status: failed
                      attempts: 3
                      createdAt: "2026-02-01T14:33:45.200Z"
                      updatedAt: "2026-02-01T14:35:22.789Z"
                      startedAt: "2026-02-01T14:33:47.456Z"
                      completedAt: null
                      failedAt: "2026-02-01T14:35:22.750Z"
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      durationMs: 97294
                      stages:
                        parsing:
                          status: failed
                          durationMs: 95100
                          retriesAttempted: 3
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error:
                        code: parsing_failed
                        message: "Failed to extract text from PDF: Document appears to be encrypted or corrupted"
                        stage: parsing
                        retryable: false
                        details:
                          parserError: InvalidPDFException
                          parserMessage: "PDF header not found or malformed"

              jobCanceled:
                summary: Job Canceled Event
                description: Fired when a job is manually canceled via API
                value:
                  event: job.canceled
                  timestamp: "2026-02-01T14:31:30.567Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "9e1f8791-9647-62f0-b938-g29be3gb2cg9"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-3g2f4d6c-0987-5432-edcb-987654321gfe"
                      filename: "large-document.pdf"
                      contentType: "application/pdf"
                      size: 15894200
                      uploadedAt: "2026-02-01T14:29:00.123Z"
                    job:
                      id: "job-2b3c4d5e-6f7g-8901-2345-678901bcdefg"
                      status: canceled
                      attempts: 1
                      createdAt: "2026-02-01T14:29:00.200Z"
                      updatedAt: "2026-02-01T14:31:30.567Z"
                      startedAt: "2026-02-01T14:29:02.456Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: "2026-02-01T14:31:30.500Z"
                      canceledBy: user
                      reason: "Manual cancellation via API"
                      durationMs: 148044
                      stages:
                        parsing:
                          status: completed
                          durationMs: 120000
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null

              jobParsingCompleted:
                summary: Parsing Completed Event
                description: |
                  Fired when the parsing stage completes successfully (before chunking). **`durationMs`** is **omitted** here because **`completedAt` / `failedAt` / `canceledAt`** are not set yet (only **`startedAt`** is present).
                value:
                  event: job.parsing_completed
                  timestamp: "2026-02-01T14:30:45.500Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "a1b2c3d4-5e6f-7890-abcd-ef1234567891"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: chunking
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:45.500Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null

              jobChunkingCompleted:
                summary: Chunking Completed Event
                description: |
                  Fired when the chunking stage completes successfully (before indexing). **`durationMs`** is **omitted** until a terminal timestamp exists on the job snapshot.
                value:
                  event: job.chunking_completed
                  timestamp: "2026-02-01T14:31:20.800Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "b2c3d4e5-6f7a-8901-bcde-f12345678902"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: indexing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:31:20.800Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: completed
                          durationMs: 35300
                          chunksCreated: 87
                        indexing:
                          status: pending
                      error: null

              jobIndexingCompleted:
                summary: Indexing Completed Event
                description: |
                  Fired when the indexing stage finishes (**before** **`job.completed`** is emitted and before the job reaches **`completed`**).
                  **`job.status`** is still **`indexing`** and **`completedAt`** is usually **null** here; **`durationMs`** is **omitted** until a terminal timestamp exists.
                value:
                  event: job.indexing_completed
                  timestamp: "2026-02-01T14:32:40.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "c3d4e5f6-7a8b-9012-cdef-123456789013"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: indexing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:32:40.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 45200
                        chunking:
                          status: completed
                          durationMs: 12400
                          chunksCreated: 87
                        indexing:
                          status: completed
                          durationMs: 92266
                          vectorsCreated: 87
                      error: null

              jobParsingStarted:
                summary: Parsing started
                description: Fired immediately before the parse step runs for an ingest job.
                value:
                  event: job.parsing_started
                  timestamp: "2026-02-01T14:30:15.300Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e1111111-2222-3333-4444-555555555501"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: parsing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:15.300Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: pending
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    parse:
                      phase: started

              jobParsingFormatCompleted:
                summary: One parse output format stored
                description: Fired after each successful parse output format upsert; use ordinal/total for progress UI.
                value:
                  event: job.parsing_format_completed
                  timestamp: "2026-02-01T14:30:18.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e2222222-2222-3333-4444-555555555502"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: parsing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:18.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: pending
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    parse:
                      format: markdown
                      ordinal: 1
                      total: 3

              jobParsingStructureReady:
                summary: Multimodal structure persisted (JSON parse path)
                description: Fired after structured layout elements are persisted when **`json`** is among the KB’s **`settings.parse.outputFormats`**.
                value:
                  event: job.parsing_structure_ready
                  timestamp: "2026-02-01T14:30:22.500Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e3333333-2222-3333-4444-555555555503"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: parsing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:22.500Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: pending
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    parse:
                      structureReady: true

              jobChunkingStarted:
                summary: Chunking started
                description: Fired immediately before the chunking step runs.
                value:
                  event: job.chunking_started
                  timestamp: "2026-02-01T14:30:46.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e4444444-2222-3333-4444-555555555504"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: chunking
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:46.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    chunking:
                      phase: started

              jobChunkingFormatSelected:
                summary: Chunking source selected
                description: Fired when chunks are created from the original file or from a parsed format fallback.
                value:
                  event: job.chunking_format_selected
                  timestamp: "2026-02-01T14:31:05.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e5555555-2222-3333-4444-555555555505"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: chunking
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:31:05.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    chunking:
                      source: original_file

              jobIndexingStarted:
                summary: Indexing started
                description: Fired immediately before embeddings are generated and vectors are written.
                value:
                  event: job.indexing_started
                  timestamp: "2026-02-01T14:31:21.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e6666666-2222-3333-4444-555555555506"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: indexing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:31:21.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: completed
                          durationMs: 35300
                          chunksCreated: 87
                        indexing:
                          status: pending
                      error: null
                    indexing:
                      phase: started

              jobParsingPartialFailure:
                summary: Partial parse failure
                description: Some parse output formats failed while at least one succeeded; job continues.
                value:
                  event: job.parsing_partial_failure
                  timestamp: "2026-02-01T14:30:25.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e7777777-2222-3333-4444-555555555507"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: parsing
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:30:25.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: pending
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    parse:
                      succeeded: [markdown, html]
                      failed:
                        - format: json
                          error: "timeout"

              jobChunkingPartialFailure:
                summary: Partial chunking failure
                description: Hybrid chunk on original file failed but a parsed-format fallback produced chunks.
                value:
                  event: job.chunking_partial_failure
                  timestamp: "2026-02-01T14:31:10.000Z"
                  webhookId: "550e8400-e29b-41d4-a716-446655440000"
                  deliveryId: "e8888888-2222-3333-4444-555555555508"
                  data:
                    project:
                      id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      name: "Production API"
                    knowledgebase:
                      id: "kb-550e8400-e29b-41d4-a716-446655440001"
                      name: "Agent Developer Documentation"
                    document:
                      id: "doc-7c9e6679-7425-40de-944b-e07fc1f90ae8"
                      filename: "user-guide.pdf"
                      contentType: "application/pdf"
                      size: 2457600
                      uploadedAt: "2026-02-01T14:30:12.456Z"
                    job:
                      id: "job-9f8e7d6c-5b4a-3210-fedc-ba9876543210"
                      status: chunking
                      attempts: 1
                      createdAt: "2026-02-01T14:30:12.500Z"
                      updatedAt: "2026-02-01T14:31:10.000Z"
                      startedAt: "2026-02-01T14:30:15.234Z"
                      completedAt: null
                      failedAt: null
                      canceledAt: null
                      canceledBy: null
                      reason: null
                      stages:
                        parsing:
                          status: completed
                          durationMs: 30266
                        chunking:
                          status: pending
                        indexing:
                          status: pending
                      error: null
                    chunking:
                      succeeded: [markdown]
                      failed:
                        - format: original_file
                          error: "Parser returned no chunks for original file"

      responses:
        "200":
          description: Webhook received and processed successfully
        "4XX":
          description: Client error — delivery may be retried per retry policy (fixed backoff between attempts)
        "5XX":
          description: Server error — delivery may be retried per retry policy (fixed backoff between attempts)

components:
  headers:
    X-RateLimit-Limit:
      schema: { type: integer }
      description: Maximum requests allowed per rate limit window.
    X-RateLimit-Remaining:
      schema: { type: integer }
      description: Requests remaining in the current window.
    X-RateLimit-Reset:
      schema: { type: integer }
      description: Unix timestamp when the rate limit window resets.
    Retry-After:
      schema: { type: integer }
      description: Seconds after which to retry (present on 429 responses).

  securitySchemes:
    developerBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        **Developer JWT** returned by **`/auth/login`**. Send as **`Authorization: Bearer {jwt_token}`** only on routes that

    projectBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        **Project access token** from **`POST /oauth/token`** with **`grant_type=client_credentials`**. Required on all
        Tokens are short-lived—refresh before expiry using the same OAuth call; scope and lifetime are reflected in the token response.

  schemas:
    # ---------- Common ----------
    MessageResponse:
      type: object
      description: |
        Simple confirmation envelope returned by endpoints that do not need a richer body.
        The **`message`** field is safe to display to operators; it is not a substitute for
        structured error detail (see **`Problem`** / RFC 7807 responses on failure).
      required: [message]
      properties:
        message:
          type: string
          description: Human-readable status or confirmation message.

    DeliveryIdResponse:
      type: object
      description: |
        Identifies one **webhook delivery HTTP attempt**. Store **`deliveryId`** and treat each delivery as **at-most-once**:
        if you have already handled this **`deliveryId`**, return **2xx** without repeating side effects. Failed deliveries may be retried;
        retries can use a **new** **`deliveryId`**, so do not dedupe on **`event` + `timestamp`** alone—use **`deliveryId`** as the idempotency key per POST.
      required: [deliveryId]
      properties:
        deliveryId:
          type: string
          format: uuid
          description: Unique identifier for a single webhook delivery attempt.

    ProblemCode:
      type: string
      description: |
        Stable **`Problem.code`** values for **`application/problem+json`**. The API may add new values in minor releases;
        treat any value not listed here as **unknown** and fall back to **`status`** and **`detail`**.
      enum:
        - artifact_content_failed
        - artifact_get_failed
        - artifact_not_found
        - artifact_not_streamable
        - artifacts_list_failed
        - blob_download_failed
        - blob_upload_failed
        - chunking_not_completed
        - credential_creation_failed
        - credential_list_failed
        - credential_name_conflict
        - credential_not_found
        - credential_revoke_failed
        - credential_revoked
        - credential_validation_failed
        - document_creation_failed
        - document_delete_failed
        - document_in_process
        - document_list_failed
        - document_not_found
        - document_reprocess_purge_failed
        - document_replace_purge_failed
        - document_update_failed
        - element_not_found
        - element_relationships_list_failed
        - embedding_error
        - email_exists
        - figure_get_failed
        - figure_not_found
        - figures_list_failed
        - file_too_large
        - format_not_available
        - insufficient_scope
        - invalid_credentials
        - invalid_cursor
        - invalid_blob_url
        - invalid_embedding_dimensions
        - invalid_filename
        - invalid_file_count
        - invalid_input
        - invalid_multipart
        - invalid_reset_code
        - job_artifacts_list_failed
        - job_cancel_failed
        - job_creation_failed
        - job_get_failed
        - job_list_failed
        - job_not_found
        - job_not_retryable
        - job_retry_failed
        - kb_creation_failed
        - kb_delete_failed
        - kb_get_failed
        - kb_list_failed
        - kb_name_duplicate
        - kb_not_found
        - kb_patch_failed
        - login_failed
        - missing_file
        - parsed_not_found
        - password_reset_delivery_failed
        - password_reset_request_failed
        - project_creation_failed
        - project_delete_failed
        - project_get_failed
        - project_list_failed
        - project_name_duplicate
        - project_not_found
        - project_update_failed
        - rate_limited
        - reset_failed
        - rerank_failed
        - related_elements_list_failed
        - relationships_list_failed
        - section_elements_list_failed
        - section_get_failed
        - section_not_found
        - sections_list_failed
        - service_unavailable
        - signup_failed
        - table_get_failed
        - table_not_found
        - tables_list_failed
        - token_issuance_failed
        - too_many_files
        - unsupported_file_format
        - webhook_delete_failed
        - webhook_delivery_failed
        - webhook_get_failed
        - webhook_not_found
        - webhook_update_failed
        - webhook_upsert_failed

    Problem:
      type: object
      description: |
        **RFC 7807 Problem Details** body used by many error responses (`Content-Type: application/problem+json`).

        **Client use:** When **`code`** is present, use it for branching and metrics; surface **`detail`** to operators and in logs.
        On **`5xx`**, log the full body. **`title`** is a short label; **`status`** repeats the HTTP status code. Extra properties may appear—ignore keys you do not recognize.
      required: [title, status, detail]
      properties:
        title:
          type: string
          description: Short error summary (e.g. **"Bad Request"**, **"Not Found"**).
        status:
          type: integer
          description: HTTP status code for this error (mirrors the response status).
        detail:
          type: string
          description: Human-readable explanation you can surface in logs or UI (not guaranteed unique per request).
        code:
          description: |
            Optional machine-readable code from **`ProblemCode`**. If omitted, branch on **`status`** and **`title`** only.
          allOf:
            - $ref: "#/components/schemas/ProblemCode"

    # ---------- Auth ----------
    Developer:
      type: object
      description: |
        Authenticated **developer account** profile (human user). Returned after signup/login and embedded
        in **`AuthLoginResponse`**. This is distinct from **project credentials** used for machine **`/oauth/token`** flows.
      required: [id, email, name, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Developer account identifier.
        email:
          type: string
          format: email
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
          description: Email address for the developer account.
        name:
          type: string
          nullable: true
          default: null
          description: Display name for the developer account.
        createdAt:
          type: string
          format: date-time
          description: When the developer account was created (ISO 8601).

    AuthSignupRequest:
      type: object
      required: [email, password]
      description: |
        Request body for creating a new developer account.
        After signup, use the login endpoint to obtain a developer JWT token.
      properties:
        email:
          type: string
          format: email
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
          default: "user@example.com"
          description: |
            Email address for the developer account.
            Must be a valid email format. This email will be used for login and account recovery.
            If the email is already registered, **`POST /auth/signup`** returns **`409`** with **`email_exists`**.
            Example: "developer@example.com"
        password:
          type: string
          format: password
          minLength: 8
          maxLength: 64
          pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,64}$'
          default: "SecurePass123!"
          description: |
            Account password. Must meet security requirements:
            - Minimum 8 characters, maximum 64 characters
            - Must contain at least one lowercase letter (a-z)
            - Must contain at least one uppercase letter (A-Z)
            - Must contain at least one digit (0-9)
            - Must contain at least one special character (!@#$%^&*)
            
            Example: "SecurePass123!"
        name:
          type: string
          nullable: true
          default: null
          description: |
            Optional display name for the developer account.
            Used for identification in the dashboard and API responses.
            Example: "John Doe"

    AuthLoginRequest:
      type: object
      required: [email, password]
      description: |
        Request body for authenticating and obtaining a developer JWT token.
        The returned token is used for project management and credential operations.
      properties:
        email:
          type: string
          format: email
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
          default: "user@example.com"
          description: |
            Email address associated with the developer account.
            Must match the email used during signup.
            Example: "developer@example.com"
        password:
          type: string
          format: password
          default: "SecurePass123!"
          description: |
            Account password for authentication.
            Must match the password set during account creation.

    AuthLoginResponse:
      type: object
      description: |
        Successful **developer (human) login** response. **`token`** is a JWT for dashboard and project-management
        routes (not for KB ingest/search). The JWT embeds a **token format identifier** that must match the server; after a
        **password reset** (or any password change), previously issued developer JWTs are rejected—call **`/auth/login`**
        again. Use **`expiresIn`** to refresh or re-login before expiry; **`developer`** echoes the account profile
        associated with the token.
      required: [token, expiresIn, developer]
      properties:
        token:
          type: string
          description: |
            Developer JWT (human session). Invalidated server-side when the account password changes; obtain a new
            token via **`POST /auth/login`**.
        expiresIn:
          type: integer
          description: Token lifetime in seconds from issuance.
        developer:
          description: Authenticated developer profile.
          allOf:
            - $ref: "#/components/schemas/Developer"

    PasswordResetRequest:
      type: object
      required: [email]
      description: |
        Request body for initiating password reset.
        Sends a reset code to the provided email address.
      properties:
        email:
          type: string
          format: email
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
          default: "user@example.com"
          description: |
            Email address of the account requesting password reset.
            A reset code will be sent to this email if the account exists.
            Example: "developer@example.com"

    PasswordResetConfirmRequest:
      type: object
      required: [email, code, newPassword]
      description: |
        Request body for confirming password reset with the code received via email.
        Use this after calling the password reset request endpoint.
      properties:
        email:
          type: string
          format: email
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
          default: "user@example.com"
          description: |
            Email address of the account resetting the password.
            Must match the email used in the password reset request.
            Example: "developer@example.com"
        code:
          type: string
          default: "123456"
          description: |
            Password reset code received via email.
            Codes expire after a limited time (typically 15-30 minutes).
            Example: "123456"
        newPassword:
          type: string
          format: password
          minLength: 8
          maxLength: 64
          pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,64}$'
          default: "NewSecurePass123!"
          description: |
            New password for the account. Must meet security requirements:
            - Minimum 8 characters, maximum 64 characters
            - Must contain at least one lowercase letter (a-z)
            - Must contain at least one uppercase letter (A-Z)
            - Must contain at least one digit (0-9)
            - Must contain at least one special character (!@#$%^&*)
            
            Example: "NewSecurePass123!"

    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      description: |
        OAuth2 Client Credentials token request (**`application/x-www-form-urlencoded`**).
        **`grant_type`**, **`client_id`**, and **`client_secret`** are all required in the form body. **HTTP Basic** is not supported.
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
          default: client_credentials
          description: |
            OAuth2 grant type. Must be **`client_credentials`**. This is the only supported grant type for project access tokens.
        client_id:
          type: string
          description: |
            Public OAuth2 client identifier for the project credential.
        client_secret:
          type: string
          format: password
          description: |
            Client secret for the project credential.

    OAuthTokenResponse:
      type: object
      description: |
        **OAuth2 Client Credentials** success payload. **`access_token`** is the **project** bearer token used on
        KB, document, job, search, and webhook APIs. **`scopes`** lists all permissions from the credential (no per-request subsetting on **`/oauth/token`**);
        **`expires_in`** is lifetime in seconds—refresh by calling **`/oauth/token`** again before expiry.
      required: [access_token, token_type, expires_in, scopes]
      properties:
        access_token:
          type: string
          description: Project access token (bearer) for machine-to-machine API calls.
        token_type:
          type: string
          enum: [Bearer]
          description: OAuth2 token type; always `Bearer` for this API.
        expires_in:
          type: integer
          description: |
            Access token lifetime in **seconds** from issuance. Treat as authoritative—refresh before it reaches zero.
            Typical value is **3600** (one hour) but clients must not assume a fixed TTL.
        scopes:
          type: array
          items:
            $ref: "#/components/schemas/Scope"
          description: |
            Permission scopes granted to this access token (same structure as `scopes` on project credentials).

    # ---------- Scopes ----------
    Scope:
      type: string
      description: |
        Permission scope for project credentials and tokens. Each scope grants access to specific API operations.
        
        **Knowledge Base scopes:**
        - `kb:read` - Read KB metadata and settings
        - `kb:delete` - Delete KBs
        
        **Document scopes:**
        - `docs:read` - Read document metadata
        - `docs:ingest` - Upload and ingest documents
        - `docs:delete` - Delete documents
        
        **Job scopes:**
        - `jobs:read` - Read job status and details
        - `jobs:cancel` - Cancel running jobs
        - `jobs:retry` - Retry failed jobs
        
        **Search scopes:**
        - `search:run` - Execute semantic and hybrid searches
        
        **Webhook scopes:**
        - `webhooks:read` - Read webhook configuration
        - `webhooks:write` - Create and update webhooks
        - `webhooks:delete` - Delete webhooks
        - `webhooks:test` - Test webhook delivery
        
        **Admin scopes:**
        - `admin:*` - Full administrative access (all scopes)
      enum:
        # KB scopes
        - kb:read
        - kb:write
        - kb:delete
        # Document scopes
        - docs:read
        - docs:ingest
        - docs:delete
        - docs:parsed:read
        # Job scopes
        - jobs:read
        - jobs:cancel
        - jobs:retry
        # Search scopes
        - search:run
        # Webhook scopes
        - webhooks:read
        - webhooks:write
        - webhooks:delete
        - webhooks:test
        # Admin
        - admin:*

    DefaultProjectScopes:
      type: array
      minItems: 1
      description: |
        Optional list of permission scopes for this credential. If omitted, default scopes are applied (see default below).
        
        Each scope grants access to specific API operations:
        
        **Knowledge Base scopes:**
        - `kb:read` - Read KB metadata and settings
        - `kb:delete` - Delete KBs
        
        **Document scopes:**
        - `docs:read` - Read document metadata
        - `docs:ingest` - Upload and ingest documents
        - `docs:delete` - Delete documents
        
        **Job scopes:**
        - `jobs:read` - Read job status and details
        - `jobs:cancel` - Cancel running jobs
        - `jobs:retry` - Retry failed jobs
        
        **Search scopes:**
        - `search:run` - Execute semantic and hybrid searches
        
        **Webhook scopes:**
        - `webhooks:read` - Read webhook configuration
        - `webhooks:write` - Create and update webhooks
        - `webhooks:delete` - Delete webhooks
        - `webhooks:test` - Test webhook delivery
        
        **Admin scopes:**
        - `admin:*` - Full administrative access (all scopes)
      items:
        $ref: "#/components/schemas/Scope"
      default:
        - kb:read
        - kb:write
        - kb:delete
        - docs:read
        - docs:ingest
        - docs:parsed:read
        - docs:delete
        - jobs:read
        - jobs:cancel
        - jobs:retry
        - search:run
        - webhooks:read
        - webhooks:write
        - webhooks:delete
        - webhooks:test

    # ---------- Projects ----------
    Project:
      type: object
      required: [id, name, settings, createdAt, updatedAt]
      description: |
        Project is the top-level container for Knowledge Bases and credentials.
        The `settings` object controls monitoring notifications: when monitoring is enabled
        and notificationEmail is null, notifications are sent to the registered developer email.
      properties:
        id:
          type: string
          format: uuid
          description: Project identifier.
        name:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
          description: Project display name.
        description:
          type: string
          nullable: true
          default: null
          description: Optional free-form description of the project.
        settings:
          type: object
          required: [monitoringEnabled]
          description: Project settings for monitoring notifications.
          properties:
            monitoringEnabled:
              type: boolean
              description: When true, project notifications (webhook failure, ingestion failure, etc.) are sent. When false, no notifications are sent.
            notificationEmail:
              type: string
              format: email
              nullable: true
              default: null
              description: Email address for notifications. When monitoring is enabled and this is null, notifications go to the registered developer email.
        createdAt:
          type: string
          format: date-time
          description: When the project was created (ISO 8601).
        updatedAt:
          type: string
          format: date-time
          description: When the project was last updated (ISO 8601).

    ProjectCreateRequest:
      type: object
      required: [name]
      description: |
        Request body for creating a new Project.
        A Project is a container for Knowledge Bases and holds machine credentials for API access.
        After creating a project, create credentials under /projects/{projectId}/credentials.
        Omit **`settings`** to use server defaults (**`monitoringEnabled: true`**, notifications to the developer email).
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
          default: "production-api"
          description: |
            Project name. Must be unique within your account; a duplicate returns **`409`** with **`project_name_duplicate`**.
            - Must start with a letter or number
            - Can contain letters, numbers, underscores, spaces, and hyphens
            - Minimum 1 character, maximum 255 characters
            
            Example: "production-api", "My Project", "test_project_1"
        description:
          type: string
          nullable: true
          default: null
          description: |
            Optional description of the project's purpose or usage.
            Useful for organizing multiple projects.
            Example: "Production API for agent-developer knowledge base"
        settings:
          type: object
          default: { monitoringEnabled: true, notificationEmail: null }
          description: |
            Project settings for monitoring notifications.
            If omitted, defaults to monitoring enabled with notifications sent to the developer account email.
          properties:
            monitoringEnabled:
              type: boolean
              default: true
              nullable: false
              description: When true, project notifications (webhook failure, ingestion failure, etc.) are sent. When false, no notifications are sent.
            notificationEmail:
              type: string
              format: email
              nullable: true
              default: null
              description: |
                Email address for notifications. When monitoring is enabled and this is null or omitted, notifications go to the developer account email.
                When we send notifications: webhook first failure, webhook recovered, document ingestion failure.
          example:
            monitoringEnabled: true
            notificationEmail: "notifications@myapp.com"

    ProjectUpdateRequest:
      type: object
      additionalProperties: false
      description: |
        Request body for updating a project (PATCH). All fields are optional; only include fields you want to change.
        Omitted **top-level** keys remain unchanged. Unknown top-level keys are rejected (**422**).

        **`settings`:** If present, **`monitoringEnabled`** and **`notificationEmail`** are updated **per key**:
        a sub-field you omit is left as stored; include **`notificationEmail`: null** to clear the stored override.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
          description: |
            New project name. Must be unique within your account; a duplicate returns **`409`** with **`project_name_duplicate`**.
            - Must start with a letter or number
            - Can contain letters, numbers, underscores, spaces, and hyphens
            - Minimum 1 character, maximum 255 characters
            
            Include only if you want to rename the project.
            Example: "production-api", "My Project (Staging)"
        description:
          type: string
          nullable: true
          default: null
          description: |
            New description of the project's purpose or usage.
            Include only if you want to change the description.
            Send null or empty string to clear the description.
            Example: "Production API for agent-developer knowledge base"
        settings:
          type: object
          additionalProperties: false
          description: |
            Monitoring notification settings. Only sub-fields you include are written; others stay unchanged
            (deep merge, not replace-with-defaults).
          properties:
            monitoringEnabled:
              type: boolean
              description: Set to false to disable project notifications; true to enable.
            notificationEmail:
              type: string
              format: email
              nullable: true
              default: null
              description: Email for notifications. When monitoring enabled and null, developer email is used.

    ProjectCredential:
      type: object
      description: |
        **Machine credential** row for a project: public **`clientId`**, granted **`scopes`**, and metadata.
        The **`clientSecret`** is never returned on this shape—see **`ProjectCredentialWithSecret`** immediately after create.
        Revoked credentials retain **`revokedAt`**; do not use them at **`/oauth/token`**.
      required: [id, projectId, name, clientId, scopes, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Credential record identifier.
        projectId:
          type: string
          format: uuid
          description: Project this credential belongs to.
        name:
          type: string
          description: |
            Credential name (unique within the project). Use this to distinguish multiple credentials
            for the same project (e.g., "prod", "staging", "dev").
        clientId:
          type: string
          description: Public OAuth2 client identifier (used with client_secret at /oauth/token).
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
          description: Permission scopes granted to tokens minted with this credential.
        createdAt:
          type: string
          format: date-time
          description: When the credential was created (ISO 8601).
        revokedAt:
          type: string
          format: date-time
          nullable: true
          default: null
          description: When the credential was revoked, if applicable; null while active.

    ProjectCredentialCreateRequest:
      type: object
      description: |
        Request body for creating project credentials (client_id/client_secret pair).
        Credentials are used for machine-to-machine authentication via OAuth2 Client Credentials flow.
        The client_secret is only returned once - store it securely.
        
        **`name`** is required and must be unique within the project (same name cannot be reused while a row with that name exists,
        including revoked credentials).
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          description: |
            Name for identifying this credential (unique within the project, including revoked rows). A duplicate returns
            **`409`** with **`credential_name_conflict`**.
            Useful when managing multiple credentials for the same project (e.g., "prod", "staging", "dev").
            Example: "production-credentials"
        scopes:
          $ref: "#/components/schemas/DefaultProjectScopes"
          description: |
            Optional list of permission scopes for this credential (must not be empty).
            
            If omitted, the full **default scope set** is applied (same as `DefaultProjectScopes` in this spec):
            `kb:read`, `kb:write`, `kb:delete`, `docs:read`, `docs:ingest`, `docs:parsed:read`, `docs:delete`,
            `jobs:read`, `jobs:cancel`, `jobs:retry`, `search:run`, `webhooks:read`, `webhooks:write`,
            `webhooks:delete`, `webhooks:test`.
            
            If specified, only the provided scopes will be granted. Use this to create
            credentials with limited permissions (principle of least privilege).
            
            Available scopes (each grants access to specific operations):
            - `kb:read` - Read KB metadata and settings
            - `kb:delete` - Delete KBs
            - `docs:read` - Read document metadata
            - `docs:ingest` - Upload and ingest documents
            - `docs:delete` - Delete documents
            - `jobs:read` - Read job status and details
            - `jobs:cancel` - Cancel running jobs
            - `jobs:retry` - Retry failed jobs
            - `search:run` - Execute semantic and hybrid searches
            - `webhooks:read` - Read webhook configuration
            - `webhooks:write` - Create and update webhooks
            - `webhooks:delete` - Delete webhooks
            - `webhooks:test` - Test webhook delivery
            - `admin:*` - Full administrative access (all scopes)

    ProjectCredentialWithSecret:
      allOf:
        - $ref: "#/components/schemas/ProjectCredential"
        - type: object
          required: [clientSecret]
          properties:
            clientSecret:
              type: string
              description: Secret (only returned once)

    PaginatedProjects:
      type: object
      description: |
        Cursor-paginated list of **`Project`** resources. **`items`** is the current page; **`nextCursor`** is the
        last **`Project.id`** on the page—pass it as **`cursor`** on **`GET /projects`** unchanged until **`nextCursor`** is null.
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Project" }
          description: Projects in the current page.
        nextCursor:
          type: string
          nullable: true
          default: null
          description: Opaque cursor for the next page; null when there are no more results.

    # ---------- KBs ----------
    PaginatedKnowledgebases:
      type: object
      description: |
        Cursor-paginated list of **`Knowledgebase`** resources within a project. **`nextCursor`** is the last KB’s **`id`**
        (UUID) on the page—pass as **`cursor`** on **`GET .../kbs`** unchanged; null means no further pages.
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Knowledgebase" }
          description: Knowledge bases in the current page.
        nextCursor:
          type: string
          nullable: true
          default: null
          description: Opaque cursor for the next page; null when there are no more results.

    Knowledgebase:
      type: object
      description: |
        **Knowledge base** resource: a named container for documents, ingest configuration, and search behavior.
        **`settings`** holds the full persisted **`KnowledgebaseSettings`** profile (parse, chunk, index, search, pipeline).
        All documents and jobs in this KB inherit those settings until you update the KB.
      required: [id, projectId, name, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
          description: Knowledge base identifier.
        projectId:
          type: string
          format: uuid
          description: Owning project for this KB
        name:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
          description: Knowledge base display name.
        description:
          type: string
          nullable: true
          default: null
          description: Optional free-form description of the knowledge base.
        createdAt:
          type: string
          format: date-time
          description: When the knowledge base was created (ISO 8601).
        updatedAt:
          type: string
          format: date-time
          description: When the knowledge base was last updated (ISO 8601).
        settings:
          description: |
            **Stored settings container** — same shape as request **`KnowledgebaseSettings`**. Returned as persisted JSON
            (possibly server-normalized defaults for pipeline / parse formats / default embedding model on create).
          allOf:
            - $ref: "#/components/schemas/KnowledgebaseSettings"

    KnowledgebaseCreateRequest:
      type: object
      required: [name]
      description: |
        **POST body** for **`POST /projects/{projectId}/kbs`**. Requires **`name`**. Optional **`description`**
        and optional **`settings`** (`KnowledgebaseSettings`).

        **`settings`** is the primary **container** for pipeline, parse, chunk, index, and search configuration.
        Validated strictly: unknown keys are rejected. See **`KnowledgebaseSettings`** and child schemas for every
        nested object (e.g. **`parse.pdf`**, **`index.embeddingConfig`**).

        On create, the server may **normalize** omitted branches (**`pipeline`**, **`parse.outputFormats`**, **`index.embeddingConfig`**)
        so persisted JSON matches default ingest behavior.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[a-zA-Z0-9][a-zA-Z0-9_\s-]*$'
          default: "my-knowledge-base"
          description: |
            Knowledge Base name. Must be unique within the project; a duplicate returns **`409`** with **`kb_name_duplicate`**.
            - Must start with a letter or number
            - Can contain letters, numbers, underscores, spaces, and hyphens
            - Minimum 1 character, maximum 255 characters
            
            Example: "support-kb", "product-docs", "research_papers"
        description:
          type: string
          nullable: true
          default: null
          description: |
            Optional description of the Knowledge Base's purpose or contents.
            Useful for organizing multiple knowledge bases within a project.
            Example: "Knowledge base for support documentation and FAQs"
        settings:
          $ref: "#/components/schemas/KnowledgebaseSettings"
          description: |
            **Container** for the whole KB processing and search profile. It groups the five top-level
            objects documented under **`KnowledgebaseSettings`** ( **`pipeline`**, **`parse`**, **`chunk`**, **`index`**, **`search`** ).

            - **`pipeline`** — which ingest stages run (`parse`, `chunk`, `index`).
            - **`chunk`** — how files are **split for retrieval** (hybrid vs hierarchical, size, metadata, tables).
            - **`index`** — **`embeddingConfig`** (which **embedding model** to use; discriminated union on **`model`**), plus optional **`index.multimodal`** index toggles.
            - **`search`** — optional **`rerankConfig`** for **two-stage search** (used only when you enable reranking on search requests), plus optional **`search.multimodal`** search defaults.

            If you omit **`settings`** entirely, the API still stores sensible defaults for
            **`pipeline`**, **`parse.outputFormats`**, and **`index.embeddingConfig`**. If you omit a subtree
            (e.g. no **`chunk`**), the API uses **built-in defaults** for that part of processing.
      examples:
        basic:
          summary: Basic configuration with defaults
          description: Minimal KB creation with default parsing and chunking settings
          value:
            name: "my-knowledge-base"
            description: "A knowledge base for document processing"
        advancedPdf:
          summary: Advanced PDF processing with OCR
          description: Configuration optimized for scanned PDFs requiring OCR and table extraction
          value:
            name: "scanned-documents-kb"
            description: "Knowledge base for processing scanned PDF documents"
            settings:
              parse:
                outputFormats: ["markdown", "json"]
                pdf:
                  tableStructureMode: "ACCURATE"
                  isOcrEnabled: "yes"
              chunk:
                attachMetadata: true
                strategy:
                  method: "hybrid"
                  size: 800
                  mergePeers: true
              index:
                embeddingConfig:
                  model: "openai:text-embedding-3-small"
                  config:
                    dimensions: 512
        multilingual:
          summary: Multilingual document processing
          description: |
            Configuration for processing documents in multiple languages. OCR can be enabled for scanned PDFs;
            language packs for OCR are chosen by the platform (not configurable per KB).
          value:
            name: "multilingual-kb"
            description: "Knowledge base for multilingual documents"
            settings:
              parse:
                outputFormats: ["markdown"]
                pdf:
                  isOcrEnabled: "yes"
              chunk:
                attachMetadata: true
                strategy:
                  method: "hybrid"
                  size: 600
        codeAndFormulas:
          summary: Technical documents (multiple parse formats)
          description: |
            HTML + markdown outputs. Code/formula/picture pipeline toggles are not exposed on this API;
            use default ingest behavior or extend ingestion later.
          value:
            name: "technical-docs-kb"
            description: "Knowledge base for technical documentation"
            settings:
              parse:
                outputFormats: ["markdown", "html"]
                pdf:
                  tableStructureMode: "ACCURATE"
              chunk:
                attachMetadata: true
                strategy:
                  method: "hierarchical"
        imageRich:
          summary: Image-heavy documents (multiple output formats)
          description: |
            Requests markdown + JSON parse outputs. Per-image/classification tuning is not in the KB schema;
            use **API defaults** or future API fields.
          value:
            name: "image-documents-kb"
            description: "Knowledge base for image-rich documents"
            settings:
              parse:
                outputFormats: ["markdown", "json"]
                pdf:
                  isOcrEnabled: "yes"
              chunk:
                attachMetadata: true
                strategy:
                  method: "hierarchical"
        withRerank:
          summary: KB with reranker for two-stage search
          description: When search requests use rerank true, this KB's Cohere reranker is used.
          value:
            name: "support-kb-with-rerank"
            description: "Knowledge base with optional reranking"
            settings:
              index:
                embeddingConfig:
                  model: "openai:text-embedding-3-small"
              search:
                rerankConfig:
                  provider: cohere
                  config:
                    model: "rerank-v3.5"

    KnowledgebaseIndexSettings:
      type: object
      description: |
        **Index-stage container.** Only **`embeddingConfig`** is defined here: it selects the embedding
        **model** (OpenAI, Cohere, Google, Bedrock, Voyage, Nomic) and optional provider **`config`**.

        Use **`settings.index.embeddingConfig`**. Do not put **`embeddingConfig`** directly under **`settings`**.

        The same **`embeddingConfig`** is used to embed **chunks when indexing** and to embed **your search queries**
        (some provider **`config`** fields may default differently for documents vs queries where the schema allows).
      required: [embeddingConfig]
      properties:
        multimodal:
          $ref: "#/components/schemas/KnowledgebaseIndexMultimodalSettings"
          description: Optional multimodal index toggles (tables, captions, facets).
        embeddingConfig:
          description: |
            **Embedding configuration container** — discriminated union **`oneOf`** keyed by required string **`model`**.

            - **`model`** — selects which provider integration runs (must be one of the enum values in the union).
            - **`config`** — optional object; its allowed keys depend on **`model`** (see **`OpenAIEmbeddingConfig`**,
              **`CohereEmbeddingConfig`**, **`GoogleEmbeddingConfig`**, **`BedrockEmbeddingConfig`** (empty),
              **`VoyageEmbeddingConfig`**, **`NomicEmbeddingConfig`**). Only documented fields are accepted.

            **Supported embedding models**

            **1. OpenAI**

            - **Models**: `openai:text-embedding-3-small`, `openai:text-embedding-3-large`
            - **Provider**: OpenAI
            - **Typical use**: General-purpose semantic search
            - **`config` options**:
              - `dimensions` (optional) – shortened size when supported
            - Responses are **float vectors** only (no **`base64`** option in this API).

            **2. Cohere**

            - **Model**: `cohere:embed-v3`
            - **Provider**: Cohere
            - **Typical use**: Multilingual and long-context embeddings
            - **`config` options**:
              - `input_type` (or legacy `taskType`) – including `image` when applicable
              - `truncate` – `NONE`, `START`, or `END`

            **3. Google**

            - **Model**: `google:text-embedding-004`
            - **Provider**: Google
            - **Typical use**: GCP-native stacks and Google AI Studio projects
            - **`config` options**:
              - `task_type` (optional) – defaults: document retrieval when indexing, query retrieval when searching
              - `outputDimensionality` (optional) – reduced output size when supported

            **4. AWS Bedrock (Titan)**

            - **Model**: `aws:titan-embed-text-v1`
            - **Provider**: Amazon Bedrock (Titan G1 text)
            - **Typical use**: AWS-native deployments
            - **`config`**: must be empty or omitted (no optional tuning fields for this model in the API).

            **5. Voyage AI**

            - **Models**: `voyage:voyage-3`, `voyage:voyage-3-lite`, `voyage:voyage-3.5-lite`
            - **Provider**: Voyage AI
            - **Typical use**: High-quality semantic search and RAG
            - **`config` options**:
              - `truncation`, `output_dimension`, `output_dtype` when set
              - Voyage **`input_type`** is applied automatically (`document` for indexing, `query` for search) and cannot be set on the KB

            **6. Nomic**

            - **Model**: `nomic:nomic-embed-text-v1.5`
            - **Provider**: Nomic
            - **Typical use**: Open-weights / self-hosted embedding setups
            - **`config` options**:
              - `task_type` (or legacy `taskType`)
              - `dimensionality` (optional) – Matryoshka target size

            **Recommended defaults**

            - Start with **OpenAI `text-embedding-3-small`** or **Voyage `voyage-3`** for general-purpose semantic search.
            - Adjust the `config` block per provider as needed for your deployment.
          oneOf:
            - $ref: "#/components/schemas/OpenAIEmbeddingConfigWrapperSmall"
            - $ref: "#/components/schemas/OpenAIEmbeddingConfigWrapperLarge"
            - $ref: "#/components/schemas/CohereEmbeddingConfigWrapper"
            - $ref: "#/components/schemas/GoogleEmbeddingConfigWrapper"
            - $ref: "#/components/schemas/BedrockEmbeddingConfigWrapper"
            - $ref: "#/components/schemas/VoyageEmbeddingConfigWrapper"
            - $ref: "#/components/schemas/NomicEmbeddingConfigWrapper"
          discriminator:
            propertyName: model
            mapping:
              openai:text-embedding-3-small: "#/components/schemas/OpenAIEmbeddingConfigWrapperSmall"
              openai:text-embedding-3-large: "#/components/schemas/OpenAIEmbeddingConfigWrapperLarge"
              cohere:embed-v3: "#/components/schemas/CohereEmbeddingConfigWrapper"
              google:text-embedding-004: "#/components/schemas/GoogleEmbeddingConfigWrapper"
              aws:titan-embed-text-v1: "#/components/schemas/BedrockEmbeddingConfigWrapper"
              voyage:voyage-3: "#/components/schemas/VoyageEmbeddingConfigWrapper"
              voyage:voyage-3-lite: "#/components/schemas/VoyageEmbeddingConfigWrapper"
              voyage:voyage-3.5-lite: "#/components/schemas/VoyageEmbeddingConfigWrapper"
              nomic:nomic-embed-text-v1.5: "#/components/schemas/NomicEmbeddingConfigWrapper"
          default:
            model: openai:text-embedding-3-small
            config:
              dimensions: 1536

    KnowledgebaseSearchSettings:
      type: object
      description: |
        **Search-only settings container.** Independent of ingest **`pipeline`** stages: reranking runs
        when a **search request** enables reranking (see **`SearchRequest`**).

        Same pattern as **`index.embeddingConfig`**: choose **`provider`**, then optional provider-specific **`config`**.
      required: [rerankConfig]
      properties:
        rerankConfig:
          description: |
            **Rerank configuration container** — a discriminated union on **`provider`** (`cohere` | `voyage` | `jina`).

            - The wrapper object **must** set **`provider`**.
            - The nested **`config`** object is optional on each wrapper; when present, it follows the matching
              **`CohereRerankConfig`**, **`VoyageRerankConfig`**, or **`JinaRerankConfig`** schema (e.g. **`model`**, **`truncation`** for Voyage/Jina).

            When search requests omit reranking or this whole **`search`** object is absent, candidates are not reranked.

            **Provider credentials:** Reranking calls the selected third-party API on your behalf. Your **workspace or deployment**
            must have valid credentials configured for that provider; if they are missing or invalid, search requests with
            reranking enabled fail with **`502`** / **`rerank_failed`** (see search endpoint). You do **not** pass provider
            secrets in this public API—only the **`provider`** and optional **`config`** object below.

            **1. Cohere** — `provider: cohere`; `config.model` optional (default **`rerank-v3.5`**). Docs: https://docs.cohere.com/reference/rerank

            **2. Voyage** — `provider: voyage`; `config.model`, `config.truncation` optional. Docs: https://docs.voyageai.com/reference/reranker-api

            **3. Jina** — `provider: jina`; `config.model`, `config.truncation` optional. Docs: https://jina.ai/reranker/

            Omit **`rerankConfig`** (or omit **`search`**) if you do not need two-stage reranking.
          oneOf:
            - $ref: "#/components/schemas/CohereRerankConfigWrapper"
            - $ref: "#/components/schemas/VoyageRerankConfigWrapper"
            - $ref: "#/components/schemas/JinaRerankConfigWrapper"
          discriminator:
            propertyName: provider
            mapping:
              cohere: "#/components/schemas/CohereRerankConfigWrapper"
              voyage: "#/components/schemas/VoyageRerankConfigWrapper"
              jina: "#/components/schemas/JinaRerankConfigWrapper"
          default:
            provider: cohere
            config:
              model: rerank-v3.5
        multimodal:
          $ref: "#/components/schemas/KnowledgebaseSearchMultimodalSettings"
          description: |
            Optional **`settings.search.multimodal`** subtree: **`retrievalModes`** (only KB-level control of which surfaces
            search uses), optional **`grounding`**, and optional **`resultExpansions`** for related ids.

    KnowledgebaseSettings:
      type: object
      description: |
        **Root settings container** for a Knowledge Base. Each property below is an optional
        subtree; together they define how **documents are processed after upload** and how **search**
        behaves for this KB.

        **Pipeline stages:** You can enable one or more of **`parse`**, **`chunk`**, and **`index`**.
        Default is all three. **`index` implies `chunk`**: if **`index`** is listed, chunking always
        runs before embedding even when **`chunk`** is omitted from **`pipeline`**.

        **Where each subtree is used**
        - **`parse`** — Controls which **outputs are produced and stored** for each upload (for example markdown and/or JSON), plus PDF-specific OCR/table shortcuts. When **`json`** is included in **`parse.outputFormats`**, the service also stores a structured representation of the document (elements, tables, figures, and relationships) that powers document inspection routes and structure-aware features.
        - **`chunk`** — Controls how stored content is **split into chunks** for retrieval (hybrid vs hierarchical strategy, target size, metadata attachment, table rendering, etc.).
        - **`index`** — Controls which **embedding model** is used and its provider configuration (vectors stored for semantic / hybrid search). Optional **`index.multimodal`** toggles additional index rows derived from stored tables/figures.
        - **`search`** — Controls search-time behavior (optional reranker and multimodal defaults). Not used during document ingest.

        All settings apply to every document in the KB unless a future document-level override exists.

        **Additional properties** at this level are not allowed (strict schema).
      required: [pipeline, parse, chunk, index, search]
      properties:
        pipeline:
          type: array
          items:
            type: string
            enum: [parse, chunk, index]
          minItems: 1
          uniqueItems: true
          default: [parse, chunk, index]
          description: |
            Ordered **ingest stage list** for every new upload. Allowed values are **`parse`**, **`chunk`**, and **`index`**;
            each at most once. **`search`** is **not** a pipeline stage—configure it under **`settings.search`**.

            **Execution order** is fixed (**parse → chunk → index**) whenever those stages are enabled.

            **What each stage does**
            - **`parse`** — Runs conversion; persists outputs per **`settings.parse.outputFormats`**.
            - **`chunk`** — Splits content using **`settings.chunk`** (hybrid vs hierarchical, token targets).
            - **`index`** — Embeds chunks using **`settings.index.embeddingConfig`** for semantic / hybrid search.

            **`index` implies chunking:** listing **`index`** always runs chunking before embeddings, even if **`chunk`** is
            omitted from this array. Use **`["parse","chunk"]`** when you want parsed artifacts and/or chunks **without** vectors.

            Invalid combinations (unknown strings, duplicates) are rejected at KB create/update validation.
          example: [parse, chunk, index]
        parse:
          description: |
            **Parse stage** (`ParseConfig`): which **output formats** are stored per upload and optional **PDF** shortcuts (OCR, table mode).
          allOf:
            - $ref: "#/components/schemas/ParseConfig"
          default:
            outputFormats: [markdown, json]
        chunk:
          description: |
            **Chunk stage** (`ChunkConfig`): how documents are **split** for retrieval and optional **structure** hints for multimodal PDFs.
          allOf:
            - $ref: "#/components/schemas/ChunkConfig"
          default:
            attachMetadata: true
            strategy:
              method: hybrid
              mergePeers: true
        index:
          description: |
            **Index stage** (`KnowledgebaseIndexSettings`): **embedding model** + provider **`config`**, and optional **`multimodal`** index toggles (table rows, captions, facets).
          allOf:
            - $ref: "#/components/schemas/KnowledgebaseIndexSettings"
          default:
            embeddingConfig:
              model: openai:text-embedding-3-small
              config:
                dimensions: 1536
        search:
          description: |
            **Search stage** (`KnowledgebaseSearchSettings`): default **rerank** provider/model and optional **`multimodal`** defaults
            (retrieval surfaces, grounding visibility, related expansion). Used by **`POST …/search`**, not during ingest.
          allOf:
            - $ref: "#/components/schemas/KnowledgebaseSearchSettings"
          default:
            rerankConfig:
              provider: cohere
              config:
                model: rerank-v3.5
        maxFileSizeMb:
          type: integer
          minimum: 2
          default: 10
          nullable: true
          description: |
            **Per-KB maximum file size** (in MB) for documents uploaded to this knowledge base — applies to both
            direct file uploads (`multipart/form-data`) and server-fetched URL content (`application/json` with `urls`).

            - **Default:** `10` MB when this field is omitted or `null`.
            - **Minimum:** `2` (must be greater than 1 MB). Values ≤ 1 are rejected at create/update time.

            Files or URL responses that exceed this limit return **`413 file_too_large`**.
          example: 5

    KnowledgebaseSettingsPatch:
      type: object
      additionalProperties: false
      description: |
        PATCH shape for KB **`settings`** when that key is present in the request body. All properties are optional; omit
        nested keys you do not want to change. The API **deep-merges** this object into stored settings, then applies the
        same **canonical defaults** as **POST** `/projects/{projectId}/kbs` (see that operation). This schema is
        permissive at the HTTP validation layer; invalid combinations may still fail at runtime with **`500`**.
      properties:
        pipeline:
          type: array
          items:
            type: string
            enum: [parse, chunk, index]
          uniqueItems: true
          description: |
            Replace **`settings.pipeline`** entirely when present. Same semantics as **`KnowledgebaseSettings.pipeline`**.
            Omit to leave the stored pipeline unchanged.
        parse:
          $ref: "#/components/schemas/ParseConfig"
          description: Deep-merge into **`settings.parse`** (output formats, PDF shortcuts). Omit to leave unchanged.
        chunk:
          $ref: "#/components/schemas/ChunkConfig"
          description: Deep-merge into **`settings.chunk`** (strategy, structure hints). Omit to leave unchanged.
        index:
          $ref: "#/components/schemas/KnowledgebaseIndexSettings"
          description: Deep-merge into **`settings.index`** (embedding model, multimodal index toggles). Omit to leave unchanged.
        search:
          $ref: "#/components/schemas/KnowledgebaseSearchSettings"
          description: Deep-merge into **`settings.search`** (rerank provider, multimodal search defaults). Omit to leave unchanged.
        maxFileSizeMb:
          type: integer
          minimum: 2
          nullable: true
          description: |
            Replace the KB's **per-KB maximum file size** (in MB). Omit to leave the stored value unchanged.
            Send `null` to clear the override and revert to the server default (10 MB).
            Must be greater than 1 (minimum 2) when a non-null value is provided.
          example: 25

    KnowledgebaseIndexMultimodalSettings:
      type: object
      additionalProperties: false
      description: |
        Optional **`settings.index.multimodal`** object. Controls **extra index rows** derived from parsed tables and figures.
        Requires parse outputs that include structured content (typically **`json`** in **`settings.parse.outputFormats`**) so
        tables/figures exist to index.
      properties:
        indexTableRows:
          type: boolean
          default: false
          description: When true, create row-level index records for tables.
        indexCaptions:
          type: boolean
          default: true
          description: When true, index caption/description text for figures.
        indexFigureClassifications:
          type: boolean
          default: false
          description: When true, index figure classification labels as facets/signals.
        facetElementTypes:
          type: boolean
          default: true
          description: When true, include element types as filterable facets in search.

    KnowledgebaseSearchMultimodalSettings:
      type: object
      additionalProperties: false
      description: |
        Optional **`settings.search.multimodal`** object. Tunes **search-time** multimodal behavior for this knowledge base.

        **`retrievalModes`** is the **only** way to choose which indexed surfaces participate in **`POST …/search`**
        (chunks, table rows, figure captions); individual search requests **cannot** override it.

        **`grounding`** and **`resultExpansions`** apply when the corresponding features are used on a search
        request (for example **`includeGrounding`** or KB-enabled related expansion).
      properties:
        retrievalModes:
          type: array
          items:
            type: string
            enum: [chunk, table_row, figure_caption]
          default: [chunk]
          description: |
            Which retrieval surfaces the search service may merge for this KB. Each value selects a class of indexed units:

            - **`chunk`** — Standard text chunks (always available once **`index`** has run).
            - **`table_row`** — Row-level table chunks when the index produced them (**`settings.index.multimodal.indexTableRows`**
              and parse outputs that include tables).
            - **`figure_caption`** — Figure / caption units when the index produced them ( **`index.multimodal.indexCaptions`**
              and related ingest).

            Order is preserved when de-duplicating. **`figure_caption`** in this list corresponds to **`figure`** hit types
            in search results and diagnostics.

            **Default** **`[chunk]`** when omitted or empty—search stays text-chunk-only unless you widen this list.
            This list is **not** settable per search call; tune it here (or via PATCH) and rely on **`SearchDiagnostics.retrievalModes`**
            to confirm the effective set at runtime.
        grounding:
          type: object
          nullable: true
          default: null
          description: |
            When **`null`** or omitted, search **`includeGrounding: true`** returns all stored grounding keys the pipeline
            produced. When present, each boolean **suppresses** that class of layout signal from hit **`grounding`** even if
            the chunk metadata contains it (smaller payloads or privacy).
          additionalProperties: false
          properties:
            includeBbox:
              type: boolean
              default: false
              description: |
                When **`false`** (default), strip **`bbox`** (and similar box geometry) from hit **`grounding`**.
                Set **`true`** only when clients are allowed to receive pixel/page bounding boxes.
            includeSectionPath:
              type: boolean
              default: true
              description: |
                When **`true`** (default), allow heading / section-path style fields in **`grounding`** when stored
                (for example **`sectionPath`** derived from headings). Set **`false`** to hide hierarchical path text.
        resultExpansions:
          type: object
          nullable: true
          default: null
          description: |
            Caps **related-element** expansion when the knowledge base enables post-search hydration of **`relatedElementIds`**
            on hits. Omit or **`null`** to use API defaults for related expansion caps.
          additionalProperties: false
          properties:
            relatedMaxPerHit:
              type: integer
              nullable: true
              default: 3
              minimum: 0
              maximum: 20
              description: |
                Maximum number of related element UUIDs to attach **per hit** after retrieval ( **`0`** disables expansion).
                Capped at **20** regardless of higher values.

    EmbeddingConfig:
      oneOf:
        - $ref: "#/components/schemas/OpenAIEmbeddingConfigWrapperSmall"
        - $ref: "#/components/schemas/OpenAIEmbeddingConfigWrapperLarge"
        - $ref: "#/components/schemas/CohereEmbeddingConfigWrapper"
        - $ref: "#/components/schemas/GoogleEmbeddingConfigWrapper"
        - $ref: "#/components/schemas/BedrockEmbeddingConfigWrapper"
        - $ref: "#/components/schemas/VoyageEmbeddingConfigWrapper"
        - $ref: "#/components/schemas/NomicEmbeddingConfigWrapper"
      discriminator:
        propertyName: model
        mapping:
          openai:text-embedding-3-small: "#/components/schemas/OpenAIEmbeddingConfigWrapperSmall"
          openai:text-embedding-3-large: "#/components/schemas/OpenAIEmbeddingConfigWrapperLarge"
          cohere:embed-v3: "#/components/schemas/CohereEmbeddingConfigWrapper"
          google:text-embedding-004: "#/components/schemas/GoogleEmbeddingConfigWrapper"
          aws:titan-embed-text-v1: "#/components/schemas/BedrockEmbeddingConfigWrapper"
          voyage:voyage-3: "#/components/schemas/VoyageEmbeddingConfigWrapper"
          voyage:voyage-3-lite: "#/components/schemas/VoyageEmbeddingConfigWrapper"
          voyage:voyage-3.5-lite: "#/components/schemas/VoyageEmbeddingConfigWrapper"
          nomic:nomic-embed-text-v1.5: "#/components/schemas/NomicEmbeddingConfigWrapper"

    OpenAIEmbeddingConfigWrapperSmall:
      type: object
      required: [model, config]
      description: |
        OpenAI embedding configuration wrapper for `openai:text-embedding-3-small`.

        See OpenAI embeddings documentation:
        - https://developers.openai.com/api/reference/resources/embeddings/methods/create
        - https://developers.openai.com/api/docs/guides/embeddings/
      properties:
        model:
          type: string
          enum: [openai:text-embedding-3-small]
          default: openai:text-embedding-3-small
          description: Discriminator selecting the OpenAI embedding model variant.
        config:
          description: |
            **Optional nested config** (`OpenAIEmbeddingConfig`): **`dimensions`**. Omit for provider defaults.
          allOf:
            - $ref: "#/components/schemas/OpenAIEmbeddingConfig"
          default:
            dimensions: 1536

    OpenAIEmbeddingConfigWrapperLarge:
      type: object
      required: [model, config]
      description: |
        OpenAI embedding configuration wrapper for `openai:text-embedding-3-large`.

        See OpenAI embeddings documentation:
        - https://developers.openai.com/api/reference/resources/embeddings/methods/create
        - https://developers.openai.com/api/docs/guides/embeddings/
      properties:
        model:
          type: string
          enum: [openai:text-embedding-3-large]
          default: openai:text-embedding-3-large
          description: Discriminator selecting the OpenAI embedding model variant.
        config:
          description: |
            **Optional nested config** (`OpenAIEmbeddingConfig`): **`dimensions`**. Omit for provider defaults.
          allOf:
            - $ref: "#/components/schemas/OpenAIEmbeddingConfig"
          default:
            dimensions: 3072

    OpenAIEmbeddingConfig:
      type: object
      description: |
        Provider-specific configuration for OpenAI text embeddings (text-embedding-3-small,
        text-embedding-3-large). All fields are optional; when omitted, OpenAI defaults are
        used. For dimensions, OpenAI defaults to 1536 for text-embedding-3-small and 3072
        for text-embedding-3-large.

        The API returns **float** embedding vectors for these models (no **`base64`** encoding option).

        See OpenAI embeddings documentation:
        - https://developers.openai.com/api/reference/resources/embeddings/methods/create
        - https://developers.openai.com/api/docs/guides/embeddings/
      properties:
        dimensions:
          type: integer
          format: int32
          minimum: 1
          default: 1536
          description: >
            Optional shortened embedding size. When omitted, OpenAI returns the model default
            length (1536 for text-embedding-3-small, 3072 for text-embedding-3-large).

    CohereEmbeddingConfigWrapper:
      type: object
      required: [model, config]
      description: |
        Cohere embedding configuration wrapper for `cohere:embed-v3`.

        See Cohere embeddings documentation:
        - https://docs.cohere.com/reference/embed
        - https://docs.cohere.com/docs/embeddings
      properties:
        model:
          type: string
          enum: [cohere:embed-v3]
          default: cohere:embed-v3
          description: Discriminator; must be `cohere:embed-v3` for this wrapper.
        config:
          description: |
            **Optional nested config** (`CohereEmbeddingConfig`): **`input_type`** / legacy **`taskType`**, **`truncate`**.
          allOf:
            - $ref: "#/components/schemas/CohereEmbeddingConfig"
          default:
            input_type: search_document
            truncate: END

    CohereEmbeddingConfig:
      type: object
      description: |
        Optional tuning for **Cohere Embed v3**. Only the fields below are accepted; embeddings are **float**
        vectors.

        See Cohere embeddings documentation:
        - https://docs.cohere.com/reference/embed
        - https://docs.cohere.com/docs/embeddings
      properties:
        input_type:
          type: string
          enum:
            - search_document
            - search_query
            - classification
            - clustering
            - image
          default: search_document
          description: >
            High-level input type hint (e.g. search_document for indexing, search_query
            for queries, classification or clustering, or image).
        truncate:
          type: string
          enum: [NONE, START, END]
          default: END
          description: >
            Truncation strategy when inputs exceed the model's maximum token length.

    GoogleEmbeddingConfigWrapper:
      type: object
      required: [model, config]
      description: |
        Google Vertex AI embedding configuration wrapper for `google:text-embedding-004`.

        See Google text embeddings documentation:
        - https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api
        - https://docs.cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings
      properties:
        model:
          type: string
          enum: [google:text-embedding-004]
          default: google:text-embedding-004
          description: Discriminator; must be `google:text-embedding-004` for this wrapper.
        config:
          description: |
            **Optional nested config** (`GoogleEmbeddingConfig`): **`task_type`**, **`outputDimensionality`**.
          allOf:
            - $ref: "#/components/schemas/GoogleEmbeddingConfig"
          default:
            outputDimensionality: 768

    GoogleEmbeddingConfig:
      type: object
      description: |
        Optional tuning for Google **`text-embedding-004`**. Only the fields below are accepted.

        See Google text embeddings documentation:
        - https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api
        - https://docs.cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings
      properties:
        task_type:
          type: string
          enum:
            - RETRIEVAL_QUERY
            - RETRIEVAL_DOCUMENT
            - SEMANTIC_SIMILARITY
            - CLASSIFICATION
            - CLUSTERING
            - QUESTION_ANSWERING
            - FACT_VERIFICATION
            - CODE_RETRIEVAL_QUERY
          default: RETRIEVAL_QUERY
          description: >
            Task type hint that guides how Google optimizes the embedding for retrieval,
            similarity, classification, QA, etc.
        outputDimensionality:
          type: integer
          format: int32
          minimum: 1
          maximum: 768
          default: 768
          description: >
            Optional reduced embedding dimensionality. When omitted, the model returns its
            full-size embedding (768 dimensions for text-embedding-004).

    BedrockEmbeddingConfigWrapper:
      type: object
      required: [model, config]
      description: |
        Amazon Bedrock embedding configuration wrapper for `aws:titan-embed-text-v1`.

        See Amazon Titan embeddings documentation:
        - https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html
        - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-titan-embed-text.html
      properties:
        model:
          type: string
          enum: [aws:titan-embed-text-v1]
          default: aws:titan-embed-text-v1
          description: Discriminator; must be `aws:titan-embed-text-v1` for this wrapper.
        config:
          description: |
            **Optional empty object** (`BedrockEmbeddingConfig`). Must be **`{}`** or omitted — this Titan embedding model has no tunable fields in this API.
          allOf:
            - $ref: "#/components/schemas/BedrockEmbeddingConfig"
          default: {}

    BedrockEmbeddingConfig:
      type: object
      additionalProperties: false
      description: |
        No optional embedding parameters for **`aws:titan-embed-text-v1`** in this API beyond choosing the model.
        The API supplies the text to embed; you do not send a raw provider request body here.

        See Amazon Titan embeddings documentation:
        - https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html
        - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-titan-embed-text.html
      properties: {}

    VoyageEmbeddingConfigWrapper:
      type: object
      required: [model, config]
      description: |
        Voyage AI embedding configuration wrapper for:
        - `voyage:voyage-3`
        - `voyage:voyage-3-lite`
        - `voyage:voyage-3.5-lite`

        See Voyage embeddings documentation:
        - https://docs.voyageai.com/docs/embeddings
        - https://docs.voyageai.com/reference/embeddings-api
      properties:
        model:
          type: string
          enum:
            - voyage:voyage-3
            - voyage:voyage-3-lite
            - voyage:voyage-3.5-lite
          default: voyage:voyage-3
          description: Discriminator selecting the Voyage embedding model variant.
        config:
          description: |
            **Optional nested config** (`VoyageEmbeddingConfig`): **`truncation`**, **`output_dimension`**, **`output_dtype`**.

            Indexify sets Voyage **`input_type`** automatically (`document` when indexing chunks, `query` when embedding search text). It is not configurable on the KB.
          allOf:
            - $ref: "#/components/schemas/VoyageEmbeddingConfig"
          default:
            truncation: true
            output_dtype: float

    VoyageEmbeddingConfig:
      type: object
      additionalProperties: false
      description: |
        Provider-specific configuration for Voyage 3-series embedding models.

        **`input_type`** is not part of this API: Indexify sends `document` when embedding
        chunks during ingest (parse → chunk → index) and `query` when embedding
        search requests.

        See Voyage embeddings documentation:
        - https://docs.voyageai.com/docs/embeddings
        - https://docs.voyageai.com/reference/embeddings-api
      properties:
        truncation:
          type: boolean
          default: true
          description: >
            Whether to automatically truncate inputs that exceed Voyage's maximum
            context length. When false, overly long inputs may be rejected.
        output_dimension:
          type: integer
          format: int32
          enum: [256, 512, 1024, 2048]
          default: 1024
          description: >
            Optional target embedding dimensionality for Voyage models. When omitted,
            Voyage uses the model default dimensionality (1024 for voyage-3.5-lite).
        output_dtype:
          type: string
          enum: [float, int8, uint8, binary, ubinary]
          default: float
          description: >
            Output data type / quantization format for Voyage 3.5 Lite embeddings.

    NomicEmbeddingConfigWrapper:
      type: object
      required: [model, config]
      description: |
        Nomic embedding configuration wrapper for `nomic:nomic-embed-text-v1.5`.

        See Nomic embeddings documentation:
        - https://docs.nomic.ai/api/python-api/generate-embeddings
        - https://docs.nomic.ai/reference/api/embed-text-v-1-embedding-text-post
        - https://huggingface.co/nomic-ai/nomic-embed-text-v1.5
      properties:
        model:
          type: string
          enum: [nomic:nomic-embed-text-v1.5]
          default: nomic:nomic-embed-text-v1.5
          description: Discriminator; must be `nomic:nomic-embed-text-v1.5` for this wrapper.
        config:
          description: |
            **Optional nested config** (`NomicEmbeddingConfig`): **`task_type`** / **`taskType`**, **`dimensionality`**.
          allOf:
            - $ref: "#/components/schemas/NomicEmbeddingConfig"
          default:
            task_type: search_document
            dimensionality: 768

    NomicEmbeddingConfig:
      type: object
      description: |
        Optional tuning for **Nomic `nomic-embed-text-v1.5`** (Matryoshka-capable). Only the fields below
        are part of this API.

        See Nomic embeddings documentation:
        - https://docs.nomic.ai/api/python-api/generate-embeddings
        - https://docs.nomic.ai/reference/api/embed-text-v-1-embedding-text-post
        - https://huggingface.co/nomic-ai/nomic-embed-text-v1.5
      properties:
        task_type:
          type: string
          enum:
            - search_query
            - search_document
            - classification
            - clustering
          default: search_document
          description: >
            Task type hint controlling how Nomic optimizes the embedding (query vs
            document vs classification/clustering).
        dimensionality:
          type: integer
          format: int32
          minimum: 64
          maximum: 768
          default: 768
          description: >
            Optional target dimensionality for Matryoshka embeddings. When omitted, Nomic
            uses the full embedding size.

    # ---------- Rerank (two-stage search) ----------
    RerankConfig:
      oneOf:
        - $ref: "#/components/schemas/CohereRerankConfigWrapper"
        - $ref: "#/components/schemas/VoyageRerankConfigWrapper"
        - $ref: "#/components/schemas/JinaRerankConfigWrapper"
      discriminator:
        propertyName: provider
        mapping:
          cohere: "#/components/schemas/CohereRerankConfigWrapper"
          voyage: "#/components/schemas/VoyageRerankConfigWrapper"
          jina: "#/components/schemas/JinaRerankConfigWrapper"
      description: |
        Provider- and model-specific reranker configuration. This is a discriminated union
        keyed by `provider`, which selects the rerank provider and activates the
        corresponding `config` schema with provider-specific options.

        **Supported rerank providers**

        **1. Cohere**

        - **Provider**: Cohere (Rerank API)
        - **Typical use**: High-quality relevance scoring for English and multilingual retrieval
        - **`config` options**:
          - `model` (optional) – Cohere rerank model. Default: `rerank-v3.5`.
            Allowed values: `rerank-v4.0-pro`, `rerank-v4.0-fast`, `rerank-v3.5`, `rerank-english-v3.0`, `rerank-multilingual-v3.0`.
        - **Docs**: https://docs.cohere.com/reference/rerank

        **2. Voyage AI**

        - **Provider**: Voyage AI
        - **Typical use**: Low-latency reranking with strong multilingual and long-context support
        - **`config` options**:
          - `model` (optional) – Voyage rerank model. Default: `rerank-2.5-lite`.
            Allowed values: `rerank-2.5`, `rerank-2.5-lite`, `rerank-2`, `rerank-2-lite`, `rerank-1`, `rerank-lite-1`.
          - `truncation` (optional) – When `true`, truncate query/documents to fit context
            length. Default: `true`.
        - **Docs**: https://docs.voyageai.com/reference/reranker-api

        **3. Jina**

        - **Provider**: Jina
        - **Typical use**: Multilingual and listwise reranking with flexible token limits
        - **`config` options**:
          - `model` (optional) – Jina rerank model. Default: `jina-reranker-v2-base-multilingual`.
            Allowed values: `jina-reranker-v3`, `jina-reranker-m0`, `jina-reranker-v2-base-multilingual`, `jina-colbert-v2`.
          - `truncation` (optional) – When `true`, truncate documents exceeding the model's
            token limit. Default: `true`.
        - **Docs**: https://jina.ai/reranker/

        **Recommended defaults**

        - Omit **`search.rerankConfig`** (or omit **`search`**) if you do not need two-stage reranking.
        - For reranking, **Cohere `rerank-v3.5`** or **Voyage `rerank-2.5-lite`** are practical starting points.
        - Ensure your **account** has the chosen provider enabled and billed; misconfiguration surfaces as search-time **`502`** errors,
          not as validation errors on KB update.

    CohereRerankConfigWrapper:
      type: object
      required: [provider, config]
      description: |
        Cohere reranker configuration for two-stage retrieval. Uses the Cohere Rerank API.
        When search requests set `rerank: true`, the server retrieves a candidate set, sends
        query and documents to Cohere, and returns results ordered by Cohere's relevance score.
        See Cohere Rerank documentation:
        - https://docs.cohere.com/reference/rerank
        - https://docs.cohere.com/docs/rerank-overview
      properties:
        provider:
          type: string
          enum: [cohere]
          default: cohere
          description: Select Cohere as the rerank provider.
        config:
          description: |
            **Optional nested config container** (`CohereRerankConfig`). Typically **`model`** only; all fields optional.
            Omit to use server default **`rerank-v3.5`**.
          allOf:
            - $ref: "#/components/schemas/CohereRerankConfig"
          default:
            model: rerank-v3.5
    CohereRerankConfig:
      type: object
      description: |
        Provider-specific configuration for Cohere Rerank. All fields are optional; when omitted,
        the API uses the defaults documented on each property. Cohere must be **enabled for your workspace** or rerank
        requests will fail at search time. See Cohere Rerank documentation:
        - https://docs.cohere.com/reference/rerank
      properties:
        model:
          type: string
          enum: [rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5, rerank-english-v3.0, rerank-multilingual-v3.0]
          default: rerank-v3.5
          description: |
            Cohere rerank model identifier. See https://docs.cohere.com/reference/rerank for details.

            **Allowed values:** `rerank-v4.0-pro`, `rerank-v4.0-fast`, `rerank-v3.5`, `rerank-english-v3.0`, `rerank-multilingual-v3.0`

            - `rerank-v4.0-pro` – SOTA quality, 32K context.
            - `rerank-v4.0-fast` – Lower latency, 32K context.
            - `rerank-v3.5` – Default; strong quality and speed, 4K context.
            - `rerank-english-v3.0` – English-only, 4K context.
            - `rerank-multilingual-v3.0` – Non-English, 4K context.

    VoyageRerankConfigWrapper:
      type: object
      required: [provider, config]
      description: |
        Voyage AI reranker configuration for two-stage retrieval. When search requests set
        `rerank: true`, the server uses Voyage's reranker to score and reorder candidates.
        Supports long context and multilingual inputs. See Voyage Reranker documentation:
        - https://docs.voyageai.com/reference/reranker-api
        - https://docs.voyageai.com/docs/reranker
      properties:
        provider:
          type: string
          enum: [voyage]
          default: voyage
          description: Select Voyage AI as the rerank provider.
        config:
          description: |
            **Optional nested config container** (`VoyageRerankConfig`). Supports **`model`** and **`truncation`**
            (defaults **`true`** when omitted in API behavior).
          allOf:
            - $ref: "#/components/schemas/VoyageRerankConfig"
          default:
            model: rerank-2.5-lite
            truncation: true
    VoyageRerankConfig:
      type: object
      description: |
        Provider-specific configuration for Voyage Reranker. All fields are optional.
        Voyage must be **enabled for your workspace** or rerank requests will fail at search time.
        See Voyage Reranker documentation:
        - https://docs.voyageai.com/reference/reranker-api
      properties:
        model:
          type: string
          enum: [rerank-2.5, rerank-2.5-lite, rerank-2, rerank-2-lite, rerank-1, rerank-lite-1]
          default: rerank-2.5-lite
          description: |
            Voyage rerank model name. See https://docs.voyageai.com/reference/reranker-api.

            **Allowed values:** `rerank-2.5`, `rerank-2.5-lite`, `rerank-2`, `rerank-2-lite`, `rerank-1`, `rerank-lite-1`

            - `rerank-2.5`, `rerank-2.5-lite` – Recommended; 32K context.
            - `rerank-2`, `rerank-2-lite` – 16K / 8K context.
            - `rerank-1`, `rerank-lite-1` – Legacy; 8K / 4K context.
        truncation:
          type: boolean
          default: true
          description: |
            When `true`, truncate the query and documents to fit the model's context length
            instead of returning an error. When `false`, the API may return an error if
            inputs exceed the limit.

    JinaRerankConfigWrapper:
      type: object
      required: [provider, config]
      description: |
        Jina reranker configuration for two-stage retrieval. When search requests set
        `rerank: true`, the server uses Jina's reranker to score and reorder candidates.
        Supports multilingual and listwise models. See Jina Reranker documentation:
        - https://jina.ai/reranker/
      properties:
        provider:
          type: string
          enum: [jina]
          default: jina
          description: Select Jina as the rerank provider.
        config:
          description: |
            **Optional nested config container** (`JinaRerankConfig`). Supports **`model`** and **`truncation`**
            (defaults **`true`** when omitted in API behavior).
          allOf:
            - $ref: "#/components/schemas/JinaRerankConfig"
          default:
            model: jina-reranker-v2-base-multilingual
            truncation: true
    JinaRerankConfig:
      type: object
      description: |
        Provider-specific configuration for Jina Reranker. All fields are optional.
        Jina must be **enabled for your workspace** or rerank requests will fail at search time.
        See Jina Reranker documentation:
        - https://jina.ai/reranker/
      properties:
        model:
          type: string
          enum: [jina-reranker-v3, jina-reranker-m0, jina-reranker-v2-base-multilingual, jina-colbert-v2]
          default: jina-reranker-v2-base-multilingual
          description: |
            Jina rerank model identifier. See https://jina.ai/reranker/ for details.

            **Allowed values:** `jina-reranker-v3`, `jina-reranker-m0`, `jina-reranker-v2-base-multilingual`, `jina-colbert-v2`

            - `jina-reranker-v3` – Listwise, 131K context, SOTA multilingual.
            - `jina-reranker-m0` – Multimodal multilingual.
            - `jina-reranker-v2-base-multilingual` – Default; 100+ languages.
            - `jina-colbert-v2` – Late interaction, 89 languages.
        truncation:
          type: boolean
          default: true
          description: |
            When `true`, truncate documents that exceed the model's token limit instead of
            failing. When `false`, the API may return an error for oversized inputs.

    ChunkTokenizer:
      type: string
      description: |
        HuggingFace model used by the hybrid chunker to **count tokens** when splitting documents.

        **Pair with your KB embedding model:**
        - **`sentence-transformers/all-MiniLM-L6-v2`** (platform default) — recommended for
          `openai:text-embedding-3-small`, `openai:text-embedding-3-large`, `cohere:embed-v3`,
          `google:text-embedding-004`, `aws:titan-embed-text-v1`, and `voyage:*` models.
        - **`nomic-ai/nomic-embed-text-v1.5`** — use when **`settings.index.embeddingConfig.model`**
          is `nomic:nomic-embed-text-v1.5`.

        **Optional on create/update:** omit **`settings.chunk.strategy.tokenizer`** entirely and the
        platform applies the default below. This is **not** a nullable field — do not send **`null`**.
        The **`default`** documents the value used when the property is **present** (including API Runner
        **Default**); **Remove** omits the key so the server applies the same platform default.
      enum:
        - sentence-transformers/all-MiniLM-L6-v2
        - nomic-ai/nomic-embed-text-v1.5
      default: sentence-transformers/all-MiniLM-L6-v2
      example: nomic-ai/nomic-embed-text-v1.5

    # ParserConvertOptions removed from public KB settings contract.


    ChunkConfig:
      type: object
      description: |
        Controls how documents are **split into chunks** for embedding and retrieval.

        **What gets chunked:** Processing normally chunks the **original uploaded file** (filename and MIME type as uploaded)
        so chunk metadata can include **page and structure** hints. If that path fails, the API may fall back to parsed text.

        **`strategy`** — chunking method and method-specific fields.

        - **`strategy.method: hybrid`** — tokenization-aware chunking with optional `size`, `mergePeers`, and `tokenizer`.
        - **`strategy.method: hierarchical`** — structure-oriented chunking (hybrid-only fields are not allowed).

        **Hybrid behavior:** If `size` is omitted, the platform uses a default token budget per chunk.

        **Token overlap:** Hybrid chunk overlap is **not** configurable per KB in this API; if overlap is applied, it follows fixed API defaults.
      required: [strategy]
      properties:
        attachMetadata:
          type: boolean
          default: true
          description: |
            When **`true`**, chunk results include **richer text and metadata** from the parsed document where available
            (for example extra context around each chunk). Optional; omit for **API defaults**.
        strategy:
          description: Method-specific chunking configuration.
          oneOf:
            - $ref: "#/components/schemas/ChunkStrategyHybrid"
            - $ref: "#/components/schemas/ChunkStrategyHierarchical"
          discriminator:
            propertyName: method
            mapping:
              hybrid: "#/components/schemas/ChunkStrategyHybrid"
              hierarchical: "#/components/schemas/ChunkStrategyHierarchical"
        structure:
          $ref: "#/components/schemas/ChunkStructureConfig"
          description: |
            Optional structure-aware chunking preferences for multimodal documents. These settings influence how chunks
            align to elements (tables, figures, sections) and how lineage is preserved.
          nullable: true
          default: null

    ChunkStrategyHybrid:
      type: object
      additionalProperties: false
      required: [method]
      properties:
        method:
          type: string
          enum: [hybrid]
          default: hybrid
          description: Must be **`hybrid`**. Token-budget chunking with optional peer merge (**`mergePeers`**) and optional **`size`**.
        size:
          type: integer
          minimum: 32
          maximum: 32000
          example: 800
          description: Target chunk size in **tokens**.
        mergePeers:
          type: boolean
          default: true
          description: Merge undersized successive chunks with matching headings/captions.
        tokenizer:
          $ref: "#/components/schemas/ChunkTokenizer"
          description: |
            **Optional.** Tokenizer for hybrid chunk **`size`** (token budget). Only values in
            **`ChunkTokenizer`** are accepted. **Omit this property** (do not send **`null`**) to let
            the platform use **`sentence-transformers/all-MiniLM-L6-v2`**. When present, **`default`**
            on **`ChunkTokenizer`** is the platform default for explicit requests.

    ChunkStrategyHierarchical:
      type: object
      additionalProperties: false
      required: [method]
      description: |
        **Hierarchical** chunking: follows document structure (sections, blocks) instead of a fixed token window.
        Does not accept **`size`** or **`mergePeers`**—use **`ChunkStrategyHybrid`** for those controls.
      properties:
        method:
          type: string
          enum: [hierarchical]
          default: hierarchical
          description: Must be **`hierarchical`**. Selects structure-first splitting when combined with **`ChunkConfig.structure`**.

    ChunkStructureConfig:
      type: object
      additionalProperties: false
      description: |
        Optional **`settings.chunk.structure`**: how hybrid/hierarchical chunking **aligns to tables, figures, and sections**
        when parse outputs expose those elements. Omit or **`null`** for API defaults (no extra structure hints).
      properties:
        respectSections:
          type: boolean
          default: true
          description: When true, avoid chunk boundaries that cross section boundaries when possible.
        tableChunkPolicy:
          type: string
          enum: [whole_table, per_row_groups, summary_plus_sample]
          default: whole_table
          description: |
            How **table** content is folded into chunks: keep each table as one unit (**`whole_table`**), group rows
            (**`per_row_groups`**), or prefer a summary plus a small sample (**`summary_plus_sample`**).
        figureChunkPolicy:
          type: string
          enum: [caption_only, caption_plus_summary, omit_figure_body]
          default: caption_plus_summary
          description: |
            How **figure** content appears in chunks: captions only, captions plus any stored summary text, or drop heavy
            figure bodies while keeping references (**`omit_figure_body`**).
        maxElementsPerChunk:
          type: integer
          minimum: 1
          maximum: 500
          default: 50
          description: |
            Soft cap on how many parsed **elements** (e.g. blocks, list items) a single chunk may span when structure-aware
            splitting is active. Higher values allow larger multi-element segments; lower values keep chunks tighter.

    PdfParseConfig:
      type: object
      additionalProperties: false
      description: |
        **PDF-focused parsing shortcuts** under **`settings.parse.pdf`**. All fields are optional.

        Use **`isOcrEnabled`** to turn OCR on or off for scanned or image-based PDFs, and **`tableStructureMode`**
        for table detection quality. OCR language packs are chosen by the platform when OCR is enabled (not configurable on the KB).
      properties:
        tableStructureMode:
          type: string
          enum: [ACCURATE, FAST]
          default: ACCURATE
          description: Table detection tradeoff — **`ACCURATE`** (higher quality) vs **`FAST`** (lower latency).
        isOcrEnabled:
          type: string
          enum: [yes, no]
          default: no
          description: |
            Whether to run OCR for scanned or image-heavy PDF pages. **`"no"`** (default) skips OCR;
            **`"yes"`** enables OCR with platform-default language settings (not configurable on the KB).

    ParseConfig:
      type: object
      additionalProperties: false
      description: |
        **Parse-stage settings** (`settings.parse`): how each uploaded file is **converted** and which **output formats**
        are stored for this knowledge base.

        **Two coordinated parts**
        1. **`outputFormats`** — formats to produce and keep (markdown, html, text, json, doctags). This also controls which
           **`format`** values are valid on **chunk** and **search** APIs for this KB.
        2. **`pdf`** — optional PDF shortcuts (OCR, table mode, etc.).

        **Processing** uses **`pipeline`**, **`parse`**, **`chunk`**, and **`index.embeddingConfig`**. **Search** uses
        **`index.embeddingConfig`** and **`search.rerankConfig`**, not **`parse`**.
      required: [outputFormats]
      properties:
        outputFormats:
          type: array
          items:
            type: string
            enum: [markdown, html, text, json, doctags]
          minItems: 1
          default: [markdown, json]
          description: |
            **List of parse output formats** to produce and store for each upload (in order).

            **API impact:** Chunk and Search endpoints require the requested **`format`** query/body parameter to appear
            in this list (or the KB’s effective default list), otherwise the API returns a format-not-available error.

            Default **`["markdown","json"]`** when omitted on create (server-normalized).
        pdf:
          description: |
            **PDF shortcuts** (`PdfParseConfig`): OCR and table detection quality for PDFs.
          allOf:
            - $ref: "#/components/schemas/PdfParseConfig"
          default:
            tableStructureMode: ACCURATE
            isOcrEnabled: "no"
    # ---------- Documents ----------
    PaginatedDocuments:
      type: object
      description: |
        Cursor-paginated **`Document`** list for a knowledge base. **`nextCursor`** is the last document’s **`id`** (UUID);
        pass as **`cursor`** on the list endpoint unchanged until null.
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Document" }
          description: Documents in the current page.
        nextCursor:
          type: string
          nullable: true
          default: null
          description: Opaque cursor for the next page; null when there are no more results.

    Document:
      type: object
      description: |
        **Uploaded file** metadata stored under a KB. Ingest jobs reference **`id`**; chunk and search APIs
        ultimately trace back to this record. **`deletedAt`** set when soft-deleted; omit or null while active.
      required: [id, kbId, filename]
      properties:
        id:
          type: string
          format: uuid
          description: Document identifier.
        kbId:
          type: string
          format: uuid
          description: Knowledge base this document belongs to.
        filename:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[^\/\\:*?"<>|]+$'
          description: Original filename supplied at upload (sanitized for storage).
        contentType:
          type: string
          example: application/pdf
          description: MIME type detected or provided for the file.
        size:
          type: integer
          minimum: 0
          description: File size in bytes.
        deletedAt:
          type: string
          format: date-time
          nullable: true
          default: null
          description: When the document was soft-deleted, if applicable; null while active.

    DocumentUploadResponse:
      type: object
      description: |
        **Single-file upload** acknowledgement. **`document`** is the persisted metadata; **`jobId`** is the
        **indexing** job enqueue id—poll job APIs or wait for webhooks to follow parse → chunk → index progress.
        **`createdAt`** is the upload time. When **`skipped`** is true, the bytes were not stored again: the KB
        already had an active (non-deleted) document with the same **`filename`**; **`jobId`** is the latest
        indexing job for that document (or a newly enqueued job if none existed).
      required: [document, jobId, createdAt]
      properties:
        document:
          description: Metadata for the uploaded file.
          allOf:
            - $ref: "#/components/schemas/Document"
        jobId:
          type: string
          format: uuid
          description: Indexing job created for this upload.
        createdAt:
          type: string
          format: date-time
          description: Upload timestamp (ISO 8601).
        skipped:
          type: boolean
          description: True when this request did not persist a new blob because the filename already exists in the KB.

    MultiDocumentUploadResponse:
      type: object
      required: [items]
      description: |
        Response for uploading one or more documents in a single request.
        The `items` array contains one entry per file in the multipart request (same order), each with its
        own document metadata and job ID; entries may include **`skipped: true`** when that filename was
        already present in the KB. Single-file uploads still return a response with one item.
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DocumentUploadResponse"
          description: One entry per file in the multipart upload, in request order.

    # ---------- Multimodal document structure ----------
    GroundingMetadata:
      type: object
      description: |
        Grounding and layout metadata linking extracted content back to the source document.
        Fields are optional and depend on document type and parse settings.
        Page location is always expressed as **`pages`** (1-indexed integers). Single-page content uses a one-element array (e.g. `[1]`).
      properties:
        pages:
          type: array
          items: { type: integer, minimum: 1 }
          minItems: 1
          description: |
            1-indexed page numbers for this content. Always an array; single-page elements use one entry (e.g. `[1]`).
            Multi-page spans list every page in ascending order.
        bbox:
          type: array
          minItems: 4
          maxItems: 4
          items: { type: number }
          description: Bounding box `[x0, y0, x1, y1]` in document coordinate space when available.
        readingOrder:
          type: integer
          minimum: 0
          description: Reading order index within a page or section when available.
        sectionPath:
          type: string
          description: Human-readable section path for UI/citations when available.

    ElementType:
      type: string
      enum: [title, heading, paragraph, list, table, figure, caption, code_block, section, container, other]
      description: |
        Stable element type. The API treats element type as a first-class signal for chunking, indexing, and ranking.

    Element:
      type: object
      description: |
        A typed multimodal element extracted from a document. Elements preserve structure and layout metadata.
      required: [id, documentId, type]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/ElementType" }
        parentElementId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Parent element in the element tree when applicable.
        sectionId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Section this element belongs to when applicable.
        grounding:
          $ref: "#/components/schemas/GroundingMetadata"
        text:
          type: string
          nullable: true
          default: null
          description: Optional plain-text projection for convenience (not the canonical representation).
        payload:
          type: object
          nullable: true
          default: null
          additionalProperties: true
          description: Type-specific payload (table metadata, figure refs, caption text, etc.).
        createdAt:
          type: string
          format: date-time
          nullable: true
          default: null
          description: When this element was persisted, when tracked.

    ElementGetResponse:
      allOf:
        - $ref: "#/components/schemas/Element"
        - type: object
          properties:
            relationships:
              type: array
              description: |
                Present when the request **`include`** query contains **`relationships`**. Directed edges touching this element (same shape as **Relationship** in **GET …/relationships**), capped server-side.
              items: { $ref: "#/components/schemas/Relationship" }

    PaginatedElements:
      type: object
      description: |
        Cursor-paginated list of **`Element`** resources. The meaning of **`nextCursor`** / **`cursor`** depends on the operation
        (element **`id`** UUID on **GET …/elements** and **GET …/sections/{sectionId}/elements**; **`document_relationships.id`** UUID on **GET …/elements/{elementId}/related**).
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Element" }
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Opaque cursor for the listing operation; see parent path documentation.

    TableCell:
      type: object
      required: [rowIndex, colIndex]
      properties:
        rowIndex: { type: integer, minimum: 0 }
        colIndex: { type: integer, minimum: 0 }
        text:
          type: string
          nullable: true
          default: null
        header:
          type: boolean
          default: false

    Table:
      type: object
      description: Structured table extracted from a document.
      required: [id, documentId]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        elementId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Element ID for the table element when applicable.
        rowCount: { type: integer, minimum: 0, default: 0 }
        colCount: { type: integer, minimum: 0, default: 0 }
        grounding:
          $ref: "#/components/schemas/GroundingMetadata"
        grid:
          type: array
          description: "Grid view (optional): list of rows, each row is a list of cells."
          items:
            type: array
            items: { $ref: "#/components/schemas/TableCell" }

    DocumentTableResponse:
      allOf:
        - $ref: "#/components/schemas/Table"
        - type: object
          description: |
            **GET …/tables/{tableId}** response. Base fields follow **`Table`**. Additional keys depend on **`view`**:
            **`view=grid`** — **`grid`** populated; **`rows`**, **`nextCursor`**, **`normalized`** omitted.
            **`view=rows`** — **`grid`** is **`null`**; **`rows`** is a page of row arrays (**`TableCell`**); **`nextCursor`** is the next row offset (decimal string) or **`null`**.
            **`view=normalized`** — **`grid`** is **`null`**; **`normalized`** holds column-oriented data.
          properties:
            rows:
              type: array
              nullable: true
              default: null
              description: Present for **`view=rows`** — each entry is one logical row of cells.
              items:
                type: array
                items: { $ref: "#/components/schemas/TableCell" }
            nextCursor:
              type: string
              nullable: true
              default: null
              description: For **`view=rows`** only — pass as **`cursor`** for the next page (row offset string).
            normalized:
              type: object
              nullable: true
              default: null
              description: Present for **`view=normalized`** — column-oriented extraction.
              properties:
                columns:
                  type: array
                  items:
                    type: object
                    required: [colIndex, cells]
                    properties:
                      colIndex: { type: integer, minimum: 0 }
                      header:
                        type: string
                        nullable: true
                        default: null
                      cells:
                        type: array
                        items: { type: string }

    PaginatedTables:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Table" }
        nextCursor:
          type: string
          nullable: true
          default: null

    Figure:
      type: object
      description: Figure/image element with optional captions and artifact links.
      required: [id, documentId]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        elementId:
          type: string
          format: uuid
          nullable: true
          default: null
        captionElementIds:
          type: array
          items: { type: string, format: uuid }
          default: []
        artifactId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Image artifact id when retained.
        contentUrl:
          type: string
          format: uri
          nullable: true
          default: null
          description: Signed URL for binary access when available (default delivery mechanism).
        grounding:
          $ref: "#/components/schemas/GroundingMetadata"
        metadata:
          type: object
          nullable: true
          default: null
          additionalProperties: true

    PaginatedFigures:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Figure" }
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Last **`Figure.id`** on this page; pass as **`cursor`** on the next request.

    Section:
      type: object
      description: Section node for navigational traversal (TOC/subtrees).
      required: [id, documentId]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        elementId:
          type: string
          format: uuid
          nullable: true
          default: null
        title:
          type: string
          nullable: true
          default: null
        parentSectionId:
          type: string
          format: uuid
          nullable: true
          default: null
        childSectionIds:
          type: array
          items: { type: string, format: uuid }
          default: []
        grounding:
          $ref: "#/components/schemas/GroundingMetadata"

    PaginatedSections:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Section" }
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Last section element **`id`** on this page; pass as **`cursor`** on the next request.

    RelationshipType:
      type: string
      enum: [parent_child, reading_order, caption_of, references_table, references_figure, section_membership]
      description: Stable relationship type.

    Relationship:
      type: object
      description: Directed relationship between two elements.
      required: [id, documentId, fromElementId, toElementId, type]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        fromElementId: { type: string, format: uuid }
        toElementId: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/RelationshipType" }
        confidence:
          type: number
          nullable: true
          default: null
        metadata:
          type: object
          nullable: true
          default: null
          additionalProperties: true

    PaginatedRelationships:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Relationship" }
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Last **`Relationship.id`** on this page; pass as **`cursor`** on the next request.

    ArtifactKind:
      type: string
      enum: [raw_docling_json, parsed_markdown, doctags, page_image, picture_image, parsed_page, caption, table_summary, classification, other]
      description: Artifact kind.

    Artifact:
      type: object
      description: |
        Processing artifact produced during parsing (including JSON normalization when **`json`** is a configured parse output). Binary payloads are accessed via signed `contentUrl` when available.
      required: [id, documentId, kind, status, createdAt]
      properties:
        id: { type: string, format: uuid }
        documentId: { type: string, format: uuid }
        jobId:
          type: string
          format: uuid
          nullable: true
          default: null
        kind: { $ref: "#/components/schemas/ArtifactKind" }
        status:
          type: string
          enum: [pending, ready, failed]
        mimeType:
          type: string
          nullable: true
          default: null
        byteSize:
          type: integer
          nullable: true
          default: null
        storageRef:
          type: object
          nullable: true
          default: null
          additionalProperties: true
          description: |
            Opaque storage reference (e.g. **`{ "blobUrl": "…" }`** for binaries, or **`{ "parsedDocumentId": "…" }`** for metadata-only kinds). Prefer **`contentUrl`** for downloads when present.
        contentUrl:
          type: string
          format: uri
          nullable: true
          default: null
          description: |
            Present only when this artifact has a **downloadable binary** and **`status`** is **`ready`**: a short-lived HTTPS URL to
            **GET …/artifacts/{artifactId}/content** (includes a signed **`token`** query parameter). Use this URL in clients instead of
            calling blob storage directly. Omit **`Authorization`** when using this URL (for example `<img src>`); do not log full URLs in plain text.
        metadata:
          type: object
          nullable: true
          default: null
          additionalProperties: true
        createdAt:
          type: string
          format: date-time

    PaginatedArtifacts:
      type: object
      required: [items, nextCursor]
      description: |
        Document-scoped artifact listing. **`nextCursor`** is the **`id`** (UUID) of the last artifact in **`items`**—use as **`cursor`** on the next request.
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Artifact" }
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: UUID of the last artifact on this page; **`null`** when there is no next page.

    PaginatedJobArtifacts:
      type: object
      required: [items, nextCursor]
      description: |
        Paginated artifacts for a single job. **`nextCursor`** is **opaque**; pass it back as **`cursor`** unchanged on the next request.
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Artifact" }
        nextCursor:
          type: string
          nullable: true
          default: null
          description: Opaque cursor for the next page, or **`null`** when there is no next page.

    PaginatedChunks:
      type: object
      required: [items, nextCursor, format]
      description: |
        Paginated JSON response for document chunks. Use `nextCursor` for the next page.
        The `format` field echoes the `format` query parameter used to build each item's `content`.
      properties:
        format:
          type: string
          enum: [markdown, html, text, json, doctags]
          description: Content projection applied to each chunk's `content` string.
        items:
          type: array
          items: { $ref: "#/components/schemas/Chunk" }
          description: |
            Array of chunk objects for the current page. Chunks are ordered by their
            `index` field, representing their position in the original document.
            The number of items returned depends on the `limit` parameter (default: 20).
        nextCursor:
          type: string
          nullable: true
          description: |
            Pagination cursor for retrieving the next page of chunks.
            - If `null`: This is the last page, no more chunks are available
            - If a string: Use this value as the `cursor` query parameter to fetch the next page
            
            Example usage:
            ```bash
            # First request
            GET /documents/{documentId}/chunks?limit=20
            
            # If nextCursor is not null, use it for the next request
            GET /documents/{documentId}/chunks?limit=20&cursor={nextCursor}
            ```
          example: "eyJkb2N1bWVudElkIjoiLi4uIiwiaW5kZXgiOjJ9"

    Chunk:
      type: object
      required: [id, documentId, index, content, format, size, createdAt]
      description: |
        A document chunk representing a portion of the parsed document content.
        Chunks are created during the chunking stage of document processing and
        inherit the format from the parsed document based on the KB's configuration.
        
        Chunks are ordered by their `index` field, which represents their position
        in the original document. Use the index to reconstruct the document order
        or to reference specific chunks.
      properties:
        id:
          type: string
          format: uuid
          description: |
            Unique identifier for the chunk. Use this ID to reference or retrieve
            a specific chunk in subsequent API calls.
          example: "550e8400-e29b-41d4-a716-446655440000"
        documentId:
          type: string
          format: uuid
          description: |
            ID of the parent document this chunk belongs to. All chunks from the same
            document will have the same documentId.
          example: "7c9e6679-7425-40de-944b-e07fc1f90ae8"
        index:
          type: integer
          minimum: 0
          description: |
            Zero-based index indicating the order of this chunk within the document.
            Lower indices appear earlier in the document. Use this field to:
            - Reconstruct the original document order
            - Navigate between chunks sequentially
            - Reference chunks by their position
            
            Example: A document with 5 chunks will have indices 0, 1, 2, 3, 4.
          example: 0
        content:
          type: string
          description: |
            Chunk body in the requested `format` query value (markdown, html, text, json, or doctags).
            
            The content represents a semantically meaningful portion of the document,
            created according to the KB's chunking strategy (`hybrid` or `hierarchical`).
          example: "# Introduction\n\nThis document provides an overview of..."
        format:
          type: string
          enum: [markdown, html, text, json, doctags]
          description: |
            Same meaning as the `format` query parameter on the request—how `content` was derived.
          example: "markdown"
        size:
          type: integer
          description: |
            Size of the chunk content in bytes. Useful for:
            - Estimating storage requirements
            - Monitoring chunk size distribution
            - Optimizing chunking parameters
            
            Typical chunk sizes range from a few hundred bytes to several kilobytes,
            depending on the chunking method and target size configuration.
          example: 1024
        metadata:
          type: object
          nullable: true
          default: null
          description: |
            Normalized citation-oriented metadata for this chunk. **`filename`** is always the original
            upload name (e.g. `report.pdf`), not a generated artifact name. **`citationRef`** is always
            present: 24 hex characters (first 12 bytes of SHA-256 over `documentId|chunkId|chunkIndex|primaryPage`,
            with an empty page segment when no primary page is set). When the KB enables rich chunk metadata
            (`attachMetadata: true`), fields may include `heading`, `headings`, `section`, `caption`, `type`,
            `sourceUri`, `mimetype`, and `labels`. Layout/grounding fields (`pages`, `bbox`, `readingOrder`,
            `sectionPath`) are **not** included here; request `settings.includeGrounding: true` on search to receive
            them under `grounding`. Noisy fields such as `binary_hash` are not exposed.
          additionalProperties: true
          example:
            filename: "dracula.pdf"
            heading: "Chapter I"
            citationRef: "a1b2c3d4e5f6789012345678"
            section: "Chapter I > Jonathan Harker's Journal"
            mimetype: "application/pdf"
        createdAt:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp indicating when the chunk was created during document
            processing. Useful for tracking processing time and cache invalidation.
          example: "2026-02-20T10:00:00Z"

    # ---------- Jobs ----------
    PaginatedJobs:
      type: object
      description: |
        the following page: it is the **`id`** (UUID) of the last job in **`items`**; pass it as the **`cursor`** query parameter
        together with the same **`status`** filter as the request that produced this page.
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/AsyncJob" }
          description: Jobs in the current page.
        nextCursor:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Next page cursor (last item’s job **`id`**), or **`null`** when there is no further page.

    RetryJobsAccepted:
      type: object
      required: [items]
      description: |
        Returned when multiple jobs are scheduled for retry (KB-wide bulk retry). Each
        element is an **`AsyncJob`** after being reset to **`pending`** for reprocessing.
        Matching jobs are updated **atomically** for that HTTP request (`items` may be empty).
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/AsyncJob" }
          description: Jobs that were reset for retry.

    AsyncJob:
      type: object
      description: |
        **Asynchronous job** representing document ingest surfaced in **`GET …/jobs`**. Typical **ingest**
        jobs (**`documentId`** set, **`reason`** often **`null`**) run parse → chunk → index.
        **`status`** is the coarse lifecycle; **`stages`** holds per-stage metrics when present; **`error`** when **`status`** is **`failed`**.

        **Timestamps:** **`completedAt`** is set when **`status`** is **`completed`**. **`failedAt`** / **`canceledAt`** are set for **`failed`** / **`canceled`** jobs. **`startedAt`** is set when workers record execution (may be **`null`** briefly after enqueue). **`canceledBy`** is set when the job was canceled (**`user`** or **`system`** when applicable).

        **`stages`** and **`error`** return structured JSON aligned with **`JobStages`** / **`JobError`** where applicable; tolerate **additional keys** or **evolved** shapes in future responses.
      required: [id, status, kbId, attempts, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
          description: Job identifier.
        status:
          type: string
          enum:
            [
              pending,
              parsing,
              chunking,
              indexing,
              completed,
              failed,
              canceled,
            ]
          description: Coarse lifecycle state (shared by ingest and other job kinds where applicable).
        documentId:
          type: string
          format: uuid
          nullable: true
          description: |
        kbId:
          type: string
          format: uuid
          description: Knowledge base this job belongs to.
        attempts:
          type: integer
          minimum: 1
          example: 1
          description: Number of processing attempts for this job.
        reason:
          type: string
          nullable: true
          description: |
        stages:
          allOf:
            - $ref: "#/components/schemas/JobStages"
        error:
          description: Present when the job failed; explains the failure. Omitted for non-failed jobs.
          allOf:
            - $ref: "#/components/schemas/JobError"
        createdAt:
          type: string
          format: date-time
          description: When the job was created (ISO 8601).
        updatedAt:
          type: string
          format: date-time
          description: When the job last changed state (ISO 8601).
        completedAt:
          type: string
          format: date-time
          nullable: true
          default: null
          description: When the job finished successfully (**`status`** **`completed`**); **`null`** for other states.
        startedAt:
          type: string
          format: date-time
          nullable: true
        failedAt:
          type: string
          format: date-time
          nullable: true
          description: When the job failed (**`status`** **`failed`**), if set.
        canceledAt:
          type: string
          format: date-time
          nullable: true
          description: When the job was canceled (**`status`** **`canceled`**), if set.
        canceledBy:
          type: string
          nullable: true
          description: Who canceled the job when applicable (e.g. **`user`**, **`system`**).

    # ---------- Search ----------
    SearchRequestSettings:
      type: object
      additionalProperties: false
      description: |
        Optional tuning for a single search call, sent as **`SearchRequest.settings`**. Omit the object entirely to use
        documented defaults for every field.

        **Defaults (omit the key):** **`rerank`** off; **`retrieveTopK`** **50**; **`searchMode`** **`semantic`**;
        **`includeSourceMetadata`** **`false`**; **`includeGrounding`** **`false`**; **`diagnostics`** **`false`**;
        **`groupBy`** and filter arrays unset (no narrowing); which retrieval surfaces run follows the knowledge base’s **`retrievalModes`** when configured, otherwise **chunk-only**;
        **`tableQuery`** unset (no table substring filter). Vector matches always apply a fixed similarity floor of **`0.5`** (not configurable per request).

        **Related hits:** when enabled for the knowledge base, hits may include **`relatedElementIds`**; that behavior is configured on the knowledge base, not in this request.

        Keep tuning here so the top-level search body stays **`query`**, **`limit`**, and **`nextCursor`**.
      properties:
        rerank:
          description: |
            Second-stage **cross-encoder reranking** for this request. Discriminate with **`isEnabled`**: **`"no"`** skips rerank;
            **`"yes"`** reranks the merged candidate pool then returns the top **`topK`** (see **`SearchRequestRerankEnabled`**).
            Provider and model come from the **knowledge base** **`settings.search.rerankConfig`**, not from this object.
          $ref: "#/components/schemas/SearchRequestRerank"
        retrieveTopK:
          type: integer
          minimum: 1
          maximum: 500
          default: 50
          description: |
            First-stage candidate list size before reranking or final trimming. Higher values retrieve more passages for
            the model to consider; latency and cost increase with very large values. Capped at **500**.
        searchMode:
          type: string
          enum: [semantic, hybrid]
          default: semantic
          description: |
            **`semantic`** uses dense vector similarity only. **`hybrid`** adds keyword retrieval and merges the two ranked
            lists so keyword-only matches can appear alongside vector hits.
        includeSourceMetadata:
          type: boolean
          default: false
          description: |
            When **`true`**, hits include stable citation fields: **`chunkId`**, **`chunkIndex`**, and a **`metadata`** object
            (original filename, a stable **`citationRef`**, and headings, MIME type, labels, and similar attributes when stored).
            Use this when your app shows sources, builds links, or feeds a model with verifiable references. Layout fields such as
            page or bounding box are not duplicated here; request **`includeGrounding`** and read **`grounding`** when you need them.
        includeGrounding:
          type: boolean
          default: false
          description: |
            When **`true`**, each hit may include a **`grounding`** object with layout-style fields when they exist in stored metadata
            (for example page, pages, bbox, reading order, section path). Visibility of some fields may still follow **knowledge base**
            grounding defaults.
        diagnostics:
          type: boolean
          default: false
          description: |
            When **`true`**, the **`SearchResponse`** includes **`SearchDiagnostics`**: candidate counts, effective retrieval modes,
            rerank usage, timing, and related metrics. Intended for tuning, regression tests, and dashboards—not required for production
            end users. Never includes the raw query text.

        # ---- Multimodal extensions (additive) ----
        elementTypes:
          type: array
          items: { $ref: "#/components/schemas/ElementType" }
          nullable: true
          default: null
          description: |
            When non-empty, only chunks whose stored primary element type is in this list are eligible. Omit or **`null`** for no filter.
            Empty arrays are treated the same as unset.
        groupBy:
          type: string
          enum: [document, section]
          nullable: true
          default: null
          description: |
            After scoring, keep only the strongest hit per bucket:
            - **`document`:** at most one hit per document.
            - **`section`:** at most one hit per section when a section id is present; otherwise falls back to one hit per document.
        tableQuery:
          type: object
          nullable: true
          default: null
          additionalProperties: false
          description: |
            Optional filter for **`table_row`** hits. **`cell_contains`** matches a plain substring in normalized cell text (not SQL).
            Omit **`tableQuery`** entirely when you do not need table filtering. If you send this object, **`cellContains`** must be a
            non-empty string after trimming; whitespace-only values are ignored as “no filter” by the API.
          properties:
            mode:
              type: string
              enum: [cell_contains]
              default: cell_contains
              description: Table filter mode; **`cell_contains`** is the supported value.
            cellContains:
              type: string
              minLength: 1
              description: Non-empty substring to match against normalized table cell text for **`table_row`** hits.

    SearchRequestRerank:
      description: |
        **Union** for **`settings.rerank`**. Send exactly **one** object matching either **`SearchRequestRerankDisabled`**
        or **`SearchRequestRerankEnabled`** (discriminator field **`isEnabled`**):

        - **`"no"`** — single-stage retrieval only (vector and/or keyword merge, no reranker call).
        - **`"yes"`** — rerank the merged first-stage list, then return the strongest matches (see **`topK`** on the enabled variant).
      oneOf:
        - $ref: "#/components/schemas/SearchRequestRerankDisabled"
        - $ref: "#/components/schemas/SearchRequestRerankEnabled"
      discriminator:
        propertyName: isEnabled
        mapping:
          "no": "#/components/schemas/SearchRequestRerankDisabled"
          "yes": "#/components/schemas/SearchRequestRerankEnabled"

    SearchRequestRerankDisabled:
      type: object
      additionalProperties: false
      description: Explicitly **turns off** reranking for this search call regardless of KB defaults.
      required: [isEnabled]
      properties:
        isEnabled:
          type: string
          enum: ["no"]
          default: "no"
          description: Must be **`"no"`** — do not run the reranker; return ordering from vector / hybrid fusion only.

    SearchRequestRerankEnabled:
      type: object
      additionalProperties: false
      description: |
        **Enables** second-stage reranking for this call. Requires a valid **rerank** configuration on the knowledge base;
        if the rerank step fails, the API returns **`502`** **`rerank_failed`**.
      required: [isEnabled]
      default:
        isEnabled: "yes"
        topK: 5
      properties:
        isEnabled:
          type: string
          enum: ["yes"]
          default: "yes"
          description: Must be **`"yes"`** — run the configured reranker over the first-stage candidate list.
        topK:
          type: integer
          minimum: 1
          maximum: 100
          default: 5
          description: |
            Number of results to return after reranking. If omitted, the API uses **5**, capped by the request **`limit`**
            and the merged candidate count.

    SearchRequest:
      type: object
      additionalProperties: false
      required: [query]
      description: |
        Search request for a knowledge base: required **`query`**, optional **`limit`** and **`nextCursor`**, and optional **`settings`**
        for retrieval tuning. How each hit’s **`content`** is formatted is controlled by the **`format`** query parameter on the route, not this body.
      properties:
        query:
          type: string
          default: "example query"
          description: |
            Natural-language or keyword text to match against indexed content. The service embeds this string with the knowledge base’s
            embedding model for vector stages; in **hybrid** mode, keyword retrieval may also run.

            **Normalization:** leading and trailing whitespace are trimmed. Empty-after-trim values are rejected (**400**).
            If the embedding provider returns an empty vector, **`semantic`** mode skips vector retrieval (often no hits); **`hybrid`** may still return keyword matches (**`SearchDiagnostics.emptyQueryEmbedding`** is **`true`**).

            Typical shapes: full questions, short phrases, or product terms (for example “refund policy”, “API key rotation”).
        limit:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
          description: |
            Maximum hits in this response page (default **10**, min **1**, max **100**).
            With reranking enabled, the post-rerank count is capped by **`settings.rerank.topK`** (default **5** when omitted) and never exceeds this **`limit`**.
            When more results exist, **`nextCursor`** in the response is non-null—pass it here for the next page (same **`query`** and **`settings`**).
        nextCursor:
          type: string
          nullable: true
          default: null
          description: |
            Continuation token from the previous **`SearchResponse.nextCursor`**. Omit or set **`null`** on the first request.

            If reranking is disabled and **`settings.searchMode`** is **`semantic`** (not **`hybrid`**): **`nextCursor`** is a
            base-10 non-negative integer string (offset into the merged candidate list). Digits only—no UUIDs.

            If reranking is enabled or **`settings.searchMode`** is **`hybrid`**: pagination is not supported; responses return **`nextCursor`: `null`**. Omit this field or send **`null`** on follow-up requests; a non-null value may return **422** if invalid.
        settings:
          description: |
            Optional nested **`settings`** object (see **`SearchRequestSettings`**). Omitted or **`{}`** uses server defaults
            for every tuning key. Unknown keys are rejected (**`400`** **`invalid_input`**).
          $ref: "#/components/schemas/SearchRequestSettings"
          default: {}
      example:
        query: "example query"
        limit: 10
        settings:
          rerank:
            isEnabled: "yes"
            topK: 5
          retrieveTopK: 50
          searchMode: hybrid
          includeSourceMetadata: false
          includeGrounding: false
          diagnostics: false
          groupBy: document
          elementTypes: []
          tableQuery:
            mode: cell_contains
            cellContains: "policy"

    SearchResult:
      type: object
      required: [documentId, filename, content, score]
      description: |
        One ranked search hit. Always includes **`documentId`**, **`filename`**, projected **`content`** (see the **`format`** query parameter), and **`score`**.
        When **`includeSourceMetadata`** was **`true`** on the request, hits also include **`chunkId`**, **`chunkIndex`**, and **`metadata`** for citations and follow-up reads.
      properties:
        documentId:
          type: string
          format: uuid
          description: Document containing the matched chunk.
        filename:
          type: string
          description: Original document filename for display and citations.
        content:
          type: string
          description: Snippet or body of the matched chunk in the requested `format`.
        score:
          type: number
          description: Similarity score (or final score when rerank was used).
        rerankScore:
          type: number
          description: Relevance score from the reranker when rerank was true. Omitted otherwise.
        chunkId:
          type: string
          format: uuid
          description: Chunk UUID for stable citations. **Included when `settings.includeSourceMetadata` was true on the request.**
        chunkIndex:
          type: integer
          description: Zero-based chunk order within the document. **Included when `settings.includeSourceMetadata` was true on the request.**
        metadata:
          type: object
          additionalProperties: true
          description: |
            **Present only when `includeSourceMetadata` was true on the request.** Stable citation-oriented fields: at minimum
            **`filename`** (original document name) and **`citationRef`** (short stable identifier for this chunk in this document).
            Additional attributes such as **`heading`**, **`headings`**, **`caption`**, **`type`**, **`sourceUri`**, **`mimetype`**, and **`labels`**
            are included when present in stored metadata.
            Layout/grounding fields (**`pages`**, **`bbox`**, **`readingOrder`**, **`sectionPath`**) are **not**
            included here; request `settings.includeGrounding: true` to receive them under `grounding`.
            Noisy upstream fields such as `binary_hash` are omitted.

        # ---- Multimodal extensions (additive) ----
        hitType:
          type: string
          enum: [chunk, table_row, figure]
          nullable: true
          default: null
          description: |
            Retrieval surface that produced this hit:
            - `chunk`: classic chunk result
            - `table_row`: row-level table result
            - `figure`: figure/caption result
        elementId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Primary element id grounding this hit when available.
        elementIds:
          type: array
          items: { type: string, format: uuid }
          nullable: true
          default: null
          description: Optional list of element ids contributing to this hit (lineage).
        sectionId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Section identifier associated with the hit when available.
        tableId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Table identifier for `hitType=table_row` hits.
        figureId:
          type: string
          format: uuid
          nullable: true
          default: null
          description: Figure identifier for `hitType=figure` hits.
        grounding:
          $ref: "#/components/schemas/GroundingMetadata"
        relatedElementIds:
          type: array
          items: { type: string, format: uuid }
          nullable: true
          default: null
          description: Optional related element ids when the KB enables related expansion defaults.

    SearchDiagnostics:
      type: object
      additionalProperties: false
      description: |
        Present on **`SearchResponse`** when the request set **`settings.diagnostics`: true**. Summarizes how the search
        pass ran (no query text). Use for debugging retrieval, agent refinement, and eval dashboards.
      required:
        - searchMode
        - rerank
        - retrieveTopK
        - rerankTopK
        - limit
        - scoreThreshold
        - mergedCandidateCount
        - chunkCandidateCount
        - tableRowCandidateCount
        - figureCandidateCount
        - rerankResultCount
        - resultCount
        - retrievalModes
        - durationMs
        - emptyQueryEmbedding
      properties:
        searchMode:
          type: string
          enum: [semantic, hybrid]
        rerank:
          type: boolean
        retrieveTopK:
          type: integer
          minimum: 1
          maximum: 500
        rerankTopK:
          type: integer
          nullable: true
          minimum: 1
          maximum: 100
          description: Target count after rerank when reranking ran; **`null`** when rerank was not used.
        limit:
          type: integer
        scoreThreshold:
          type: number
        mergedCandidateCount:
          type: integer
          minimum: 0
          description: Rows in the merged candidate list after vector/hybrid fusion (before final slice to **`limit`**).
        chunkCandidateCount:
          type: integer
          minimum: 0
        tableRowCandidateCount:
          type: integer
          minimum: 0
        figureCandidateCount:
          type: integer
          minimum: 0
        rerankResultCount:
          type: integer
          nullable: true
          minimum: 0
          description: Number of documents passed to the reranker when rerank ran; **`null`** when rerank was not used.
        resultCount:
          type: integer
          minimum: 0
          description: Number of hits returned after grouping and related expansion.
        retrievalModes:
          type: array
          items:
            type: string
            enum: [chunk, table_row, figure_caption]
          description: Effective retrieval modes from the knowledge base configuration for this pass (mirrors **`settings.search.multimodal.retrievalModes`** when set).
        durationMs:
          type: integer
          minimum: 0
          description: Server-side search duration in milliseconds.
        emptyQueryEmbedding:
          type: boolean
          description: >-
            **`true`** when the embedding provider returned an empty vector: no vector similarity retrieval ran.
            In **`hybrid`** mode, keyword/full-text retrieval may still have run; in **`semantic`** mode, hits are empty unless other paths apply.
        elementTypeHits:
          type: object
          description: |
            When diagnostics are on and the knowledge base has element-type faceting enabled for search, maps each
            **primary element type** label on merged **candidate** rows to a count (before the final page **`limit`** and grouping).
          additionalProperties:
            type: integer
            minimum: 0

    SearchResponse:
      type: object
      description: |
        One page of search results. **`format`** echoes the **`format`** query parameter and describes how each hit’s **`content`**
        was built. **`results`** are ordered by relevance for this request.

        **`nextCursor`:** when reranking is off and **`searchMode`** was **`semantic`**, a non-**`null`** value is a **base-10 digit string**
        offset for the next page. Otherwise **`null`** (including when there are no more pages, or when **hybrid** or reranking is used).

        **`diagnostics`:** present only when the request set **`settings.diagnostics`** to **`true`**.
      required: [results, nextCursor, format]
      properties:
        format:
          type: string
          enum: [markdown, html, text, json, doctags]
          description: Echoes the `format` query parameter; controls how each result's `content` is built.
        results:
          type: array
          items: { $ref: "#/components/schemas/SearchResult" }
          description: Ranked hits for this page.
        nextCursor:
          type: string
          nullable: true
          default: null
          description: |
            Next page token. **`null`** when there is no next page, or when **`settings.rerank`** or **`hybrid`** mode applies (no cursor pagination in those cases).
            When non-**`null`**, pass this string unchanged as **`nextCursor`** on the following request (semantic, non-rerank only); it is a **decimal offset string**, not a UUID.
        diagnostics:
          $ref: "#/components/schemas/SearchDiagnostics"

    # ---------- Webhooks ----------
    Webhook:
      type: object
      description: |
        **Per-KB webhook** configuration (at most one per knowledge base). Subscribed **`events`** drive POST
        deliveries to **`url`** with HMAC signing. **`active`** gates new deliveries; **`lastFailureAt`** surfaces
        the latest delivery failure for monitoring. **`retryPolicy`** controls backoff between attempts.
      required: [id, kbId, url, events, active, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Webhook configuration identifier.
        kbId:
          type: string
          format: uuid
          description: Knowledge base this webhook is bound to (at most one webhook per KB).
        url:
          type: string
          format: uri
          pattern: '^https?://[^\s]+$'
          description: HTTPS (or HTTP) endpoint that receives POST deliveries.
        events:
          type: array
          minItems: 1
          items:
            type: string
          description: |
            Subscribed events: **document pipeline** job lifecycle (including **granular** ingest milestones).
        active:
          type: boolean
          description: When false, the API does not enqueue new deliveries.
        createdAt:
          type: string
          format: date-time
          description: When the webhook was created (ISO 8601).
        lastFailureAt:
          type: string
          format: date-time
          nullable: true
          default: null
          description: Timestamp of the most recent failed delivery attempt, if any.
        retryPolicy:
          type: object
          nullable: true
          default: null
          description: |
            Retry policy (optional). Limits: maxAttempts 1-10, backoffSeconds 1-300.
            Constraint: (maxAttempts - 1) * backoffSeconds must not exceed 300.
          required: [maxAttempts, backoffSeconds]
          properties:
            maxAttempts:
              type: integer
              minimum: 1
              maximum: 10
              default: 1
              description: Total delivery attempts per event (1 means no retries).
            backoffSeconds:
              type: integer
              minimum: 1
              maximum: 300
              default: 30
              description: Delay between retry attempts in seconds.

    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: Returned only on create/replace/rotate

    WebhookUpsertRequest:
      type: object
      required: [url, active, retryPolicy]
      description: |
        Request body for creating or replacing a webhook configuration.
        Each Knowledge Base can have 0 or 1 webhook. This endpoint creates a new webhook
        or replaces an existing one.
        
        The webhook secret is only returned on create/replace - store it securely.
        Use it to verify HMAC signatures in webhook deliveries.
      properties:
        url:
          type: string
          format: uri
          pattern: '^https?://[^\s]+$'
          default: "https://api.example.com/webhooks/indexify"
          description: |
            HTTPS endpoint URL where webhook events will be delivered.
            Must be a valid HTTP or HTTPS URL.
            
            Your endpoint must:
            - Accept POST requests
            - Return 2xx status codes for successful processing
            - Verify HMAC signatures using the webhook secret
            
            Example: "https://api.example.com/webhooks/indexify"
        events:
          type: array
          minItems: 1
          items:
            type: string
          default:
            - job.pending
            - job.started
            - job.parsing_started
            - job.parsing_format_completed
            - job.parsing_structure_ready
            - job.parsing_partial_failure
            - job.parsing_completed
            - job.chunking_started
            - job.chunking_format_selected
            - job.chunking_partial_failure
            - job.chunking_completed
            - job.indexing_started
            - job.indexing_completed
            - job.completed
            - job.failed
            - job.canceled
          description: |
            **Document pipeline (coarse milestones):**
            - `job.pending`: Fired when an ingest job row is queued (upload, reprocess, replace, or job retry) — before **`job.started`**
            - `job.started`: Fired when a job transitions from pending to active processing
            - `job.parsing_completed`: Fired when the parsing stage completes successfully (all configured output formats attempted)
            - `job.chunking_completed`: Fired when the chunking stage completes successfully
            - `job.indexing_completed`: Fired when the indexing stage completes successfully
            - `job.completed`: Fired when a document ingestion job finishes successfully (document fully indexed)
            - `job.failed`: Fired when a document ingestion job fails at any stage after exhausting retries
            - `job.canceled`: Fired when a job is manually canceled
            
            **Document pipeline (granular progress)** — optional finer signals; payloads may include **`data.parse`**, **`data.chunking`**, or **`data.indexing`** (see **`WebhookEventData`**):
            - `job.parsing_started`: Parsing step is about to run
            - `job.parsing_format_completed`: One parse output format was stored successfully; **`data.parse`** has **`format`**, **`ordinal`**, **`total`**
            - `job.parsing_structure_ready`: Multimodal document structure was persisted (JSON parse path); **`data.parse`** includes **`structureReady: true`**
            - `job.parsing_partial_failure`: At least one parse format failed while others succeeded (job continues); **`data.parse`** includes **`succeeded`** and **`failed`**
            - `job.chunking_started`: Chunking step is about to run
            - `job.chunking_format_selected`: Chunking succeeded using **`original_file`** or a parsed format; **`data.chunking`** has **`source`**
            - `job.chunking_partial_failure`: Hybrid chunking on the original file failed but a fallback format succeeded; **`data.chunking`** includes **`succeeded`** and **`failed`**
            - `job.indexing_started`: Embedding / vector write step is about to run
            
            Subscribe to all events you need. You'll receive POST requests to your webhook URL
            for each subscribed event.
            
            **Default** (when **`events`** is omitted on create): subscribe to **all** event names above so new granular signals are received without changing your integration.
        active:
          type: boolean
          default: true
          description: |
            Whether the webhook is active and should receive events.
            - `true`: Webhook is active and will receive events (default)
            - `false`: Webhook is disabled and will not receive events
            
            Use this to temporarily disable a webhook without deleting it.
        secret:
          type: string
          nullable: true
          default: null
          description: |
            Optional custom webhook secret for HMAC signature verification.
            
            If omitted, a secure random secret will be generated automatically.
            The secret is only returned once on create/replace - store it securely.
            
            Use this secret to verify the `X-Indexify-Signature` header in webhook deliveries:
            `HMAC-SHA256(secret, request_body) == signature`
            
            Example: "whsec_abc123xyz..."
        retryPolicy:
          type: object
          default: { maxAttempts: 3, backoffSeconds: 30 }
          description: |
            Optional retry policy for failed webhook deliveries. When your endpoint
            returns non-2xx or times out, the system retries with a fixed delay between
            attempts. Invalid combinations are rejected with 400 Bad Request.
          required: [maxAttempts, backoffSeconds]
          properties:
            maxAttempts:
              type: integer
              minimum: 1
              maximum: 10
              description: |
                Total number of delivery attempts (1 = no retries).
                - Allowed: 1 to 10. Default when omitted: 1.
                After maxAttempts failures, delivery is marked as failed.
              default: 1
            backoffSeconds:
              type: integer
              minimum: 1
              maximum: 300
              description: |
                Fixed delay in seconds between attempts.
                - Allowed: 1 to 300. Default when omitted: 30.
                Constraint: (maxAttempts - 1) * backoffSeconds must not exceed 300 seconds.
              default: 30
          example:
            maxAttempts: 3
            backoffSeconds: 30

    WebhookUpdateRequest:
      type: object
      required: []
      description: |
        Request body for partially updating an existing webhook (PATCH only).
        **Include only the properties you want to update.** Omit any property entirely to leave it unchanged.
        In the request body / tree view, remove fields you do not want to patch so they are not sent.
        - **Property omitted**: not sent in body → leave unchanged.
        - **Property present with value**: update that field.
        Example minimal body (only what you are patching): `{"active": false}` or `{"url": "https://new.example.com/hook", "events": ["job.completed"]}`.
      properties:
        url:
          type: string
          format: uri
          pattern: '^https?://[^\\s]+$'
          description: |
            Webhook delivery URL. Include only to update; omit to leave unchanged.
        events:
          type: array
          minItems: 1
          items:
            type: string
          description: |
        active:
          type: boolean
          description: |
            Whether the webhook is active. Include only to change; omit to leave unchanged.
        secret:
          type: string
          description: |
            Rotate secret: include (optionally empty) to rotate and return a new secret (returned once); omit otherwise.
        retryPolicy:
          type: object
          description: |
            Retry policy (maxAttempts 1-10, backoffSeconds 1-300). Include only to update; omit to leave unchanged.
          required: [maxAttempts, backoffSeconds]
          properties:
            maxAttempts:
              type: integer
              minimum: 1
              maximum: 10
              default: 1
              description: "Total delivery attempts (1 = no retries). Default when omitted: 1."
            backoffSeconds:
              type: integer
              minimum: 1
              maximum: 300
              default: 30
              description: "Fixed delay in seconds between attempts. Default when omitted: 30."

    # ---------- Webhook Payloads ----------
    WebhookDelivery:
      type: object
      description: |
        Payload sent to your webhook endpoint for **job** lifecycle events.
        When a webhook **secret** is configured, deliveries include **`X-Indexify-Signature`**: **HMAC-SHA256** over the **raw**
        JSON body, encoded as **lowercase hex** (see **`webhooks.webhookDeliveries`**).
        When the delivery is from the webhook test endpoint, the payload may include
        top-level **`testDelivery: true`** so recipients can identify test traffic.
      required: [event, timestamp, webhookId, deliveryId, data]
      properties:
        testDelivery:
          type: boolean
          description: Present and true when this delivery was triggered by the webhook test endpoint. Omitted for real job events.
        event:
          type: string
          description: |
            Event type. **Document pipeline:** `job.*` events, including **granular** milestones (`job.parsing_format_completed`, `job.chunking_format_selected`, …).
            The synthetic event **`webhook.test`** is sent only from
            the webhook test endpoint; your endpoint may ignore it or use it to verify receipt. Test deliveries may
            include top-level **`testDelivery: true`**.
        timestamp:
          type: string
          format: date-time
          description: When this event occurred (ISO 8601)
        webhookId:
          type: string
          format: uuid
          description: ID of the webhook configuration that triggered this delivery
        deliveryId:
          type: string
          format: uuid
          description: |
            Unique id for **this HTTP delivery attempt**. Use as your **idempotency key**: if you have already processed
            this **`deliveryId`**, acknowledge with **2xx** without repeating side effects. Retries may send a **new**
            **`deliveryId`**—do not rely on **`event` + `timestamp`** alone for deduplication.
        data:
          description: Event payload with project, KB, document, and job context.
          allOf:
            - $ref: "#/components/schemas/WebhookEventData"

    WebhookEventData:
      type: object
      description: |
        **Normalized context** inside a **`WebhookDelivery`**: owning project and KB, **document** summary (real upload or placeholder),
        **`document.id` / `filename` / `contentType`** are empty strings, **`size`** is **0**, and **`uploadedAt`** reflects delivery time.
        **Granular ingest events** may also include **`parse`**, **`chunking`**, or **`indexing`** objects captured when the delivery was enqueued
        (e.g. per-format parse progress, selected chunking source, partial-failure detail).
        Consumers use this to correlate deliveries without extra API calls.
      required: [project, knowledgebase, document, job]
      properties:
        project:
          description: Owning project summary.
          allOf:
            - $ref: "#/components/schemas/WebhookProjectInfo"
        knowledgebase:
          description: Knowledge base summary.
          allOf:
            - $ref: "#/components/schemas/WebhookKnowledgebaseInfo"
        document:
          description: Document summary for this job.
          allOf:
            - $ref: "#/components/schemas/WebhookDocumentInfo"
        job:
          description: Job status and timing for this event.
          allOf:
            - $ref: "#/components/schemas/WebhookJobInfo"
        parse:
          type: object
          description: |
            Present for parse-related granular or partial-failure deliveries (`job.parsing_started`, `job.parsing_format_completed`,
            `job.parsing_structure_ready`, `job.parsing_partial_failure`). Omitted for other events.
          properties:
            format:
              type: string
              description: Parser output format for `job.parsing_format_completed` (e.g. `markdown`, `html`, `json`, `doctags`, `text`).
            ordinal:
              type: integer
              minimum: 1
              description: Number of formats successfully stored so far when `format` is set.
            total:
              type: integer
              minimum: 1
              description: Total configured parse output formats for this KB when `format` is set.
            phase:
              type: string
              enum: [started]
              description: Set for `job.parsing_started`.
            structureReady:
              type: boolean
              description: True when multimodal structure was persisted after JSON parse (`job.parsing_structure_ready`).
            succeeded:
              type: array
              items:
                type: string
              description: Formats or paths that succeeded (`job.parsing_partial_failure`).
            failed:
              type: array
              description: Formats or paths that failed while others succeeded (`job.parsing_partial_failure`).
              items:
                type: object
                properties:
                  format:
                    type: string
                  error:
                    type: string
                    nullable: true
        chunking:
          type: object
          description: |
            Present for chunk-related granular or partial-failure deliveries (`job.chunking_started`, `job.chunking_format_selected`,
            `job.chunking_partial_failure`). Omitted for other events.
          properties:
            phase:
              type: string
              enum: [started]
              description: Set for `job.chunking_started`.
            source:
              type: string
              description: |
                Chunking input source for `job.chunking_format_selected`: `original_file` when hybrid chunking on the upload succeeded,
                otherwise a parsed format name (e.g. `markdown`, `html`).
            succeeded:
              type: array
              items:
                type: string
              description: Paths that succeeded (`job.chunking_partial_failure`, e.g. `original_file`).
            failed:
              type: array
              description: Paths that failed before a fallback succeeded (`job.chunking_partial_failure`).
              items:
                type: object
                properties:
                  format:
                    type: string
                  error:
                    type: string
                    nullable: true
        indexing:
          type: object
          description: Present for `job.indexing_started` (embedding / vector write about to run). Omitted for other events.
          properties:
            phase:
              type: string
              enum: [started]

    WebhookProjectInfo:
      type: object
      description: |
        Minimal **project** identity included in webhook payloads so receivers can route or label notifications
        without fetching **`GET /projects/{id}`**.
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
          description: Project identifier.
        name:
          type: string
          description: Project display name.

    WebhookKnowledgebaseInfo:
      type: object
      description: |
        Minimal **knowledge base** identity for webhook deliveries—use with **`WebhookProjectInfo`** to locate
        the KB in your own systems.
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
          description: Knowledge base identifier.
        name:
          type: string
          description: Knowledge base display name.

    WebhookDocumentInfo:
      type: object
      description: |
        **Document** summary in webhook payloads: stable **`id`**, original **`filename`**, MIME type, size, and
        upload timestamp. Matches the core fields exposed on **`Document`** for the same id when the job has a **`documentId`**.
        **`id`**, **`filename`**, and **`contentType`** as **empty strings**, **`size`** **0**, and **`uploadedAt`** set at **delivery** time
        (typically the same instant as the top-level **`timestamp`**), not a real upload time.
      required: [id, filename, contentType, size, uploadedAt]
      properties:
        id:
          type: string
        filename:
          type: string
          description: Original filename.
        contentType:
          type: string
          description: MIME type of the uploaded file.
        size:
          type: integer
          minimum: 0
          description: File size in bytes.
        uploadedAt:
          type: string
          format: date-time
          description: |
            For real uploads: when the document was created (ISO 8601). For placeholder **`document`** objects (no **`documentId`** on the job), this is set at **webhook send** time, not an upload time.

    WebhookJobInfo:
      type: object
      description: |
        **Job** snapshot at the time of the webhook event: lifecycle **`status`**, retry **`attempts`**, timestamps,
        optional **`stages`** breakdown, optional **`reason`**, and **`error`**. Aligns with **`AsyncJob`** / public job JSON where fields overlap.
        **`durationMs`** is included only when both **`startedAt`** and a terminal time (**`completedAt`**, **`failedAt`**, or **`canceledAt`**) are set.
        **`estimatedDurationMs`** appears only for **`job.pending`** and **`job.started`** deliveries and is **`null`** today.
      required: [id, status, attempts, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            [
              pending,
              parsing,
              chunking,
              indexing,
              completed,
              failed,
              canceled,
            ]
          description: Job lifecycle state at the time of the event.
        attempts:
          type: integer
          minimum: 1
          description: Processing attempts so far.
        createdAt:
          type: string
          format: date-time
          description: When the job was created (ISO 8601).
        updatedAt:
          type: string
          format: date-time
          description: When the job was last updated (ISO 8601); included on webhook deliveries.
        startedAt:
          type: string
          format: date-time
          description: When processing started, if started.
        completedAt:
          type: string
          format: date-time
          description: When the job completed successfully, if applicable.
        failedAt:
          type: string
          format: date-time
          description: When the job failed, if applicable.
        canceledAt:
          type: string
          format: date-time
          description: When the job was canceled, if applicable.
        canceledBy:
          type: string
          enum: [user, system]
          description: Who initiated cancellation, when canceled.
        reason:
          type: string
          nullable: true
          description: Optional job reason or cancel message when set on the job row.
        durationMs:
          type: integer
          minimum: 0
          description: |
            Elapsed **`startedAt` → terminal time** in milliseconds when both are set; **omitted** for many in-flight stage events when
            **`startedAt`** is not yet recorded.
        estimatedDurationMs:
          type: integer
          nullable: true
          description: |
            Present only on **`job.pending`** and **`job.started`** deliveries; currently always **`null`** (placeholder for future estimates).
        stages:
          description: Per-stage breakdown when included in the payload.
          allOf:
            - $ref: "#/components/schemas/JobStages"
        error:
          description: Error details when the job failed.
          allOf:
            - $ref: "#/components/schemas/JobError"

    JobStages:
      type: object
      description: |
        Optional **per-stage** metrics for **ingest** jobs (parse / chunk / index). Omitted keys mean that stage has not started or no metrics were recorded. Used on **`AsyncJob`**
        responses and webhook payloads.
      properties:
        parsing:
          description: Parse stage metrics (omit if not started).
          allOf:
            - $ref: "#/components/schemas/JobStageInfo"
        chunking:
          description: Chunk stage metrics (omit if not started).
          allOf:
            - $ref: "#/components/schemas/JobStageInfo"
        indexing:
          description: Index / embedding stage metrics (omit if not started).
          allOf:
            - $ref: "#/components/schemas/JobStageInfo"

    JobStageInfo:
      type: object
      description: |
        **Single-stage** outcome: completion **`status`**, observed **`durationMs`**, retry count, and optional
        counters (**`chunksCreated`**, **`vectorsCreated`**) when available. Present under **`JobStages`**
        for parse, chunk, or index stages.
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, completed, failed]
          description: Outcome of this stage.
        durationMs:
          type: integer
          minimum: 0
          description: Wall-clock time spent in this stage (milliseconds), when measured.
        retriesAttempted:
          type: integer
          minimum: 0
          description: How many retries were attempted for this stage.
        chunksCreated:
          type: integer
          minimum: 0
          description: Chunks produced in this stage (often set on chunking/indexing).
        vectorsCreated:
          type: integer
          minimum: 0
          description: Embeddings or vectors written in this stage (often indexing).

    JobError:
      type: object
      description: |
        **Normalized failure** attached to a job when processing stops in an error state. **`stage`** locates the
        failing pipeline step; **`retryable`** hints whether a client-driven retry may succeed; **`details`**
        may contain provider-specific diagnostics and is not guaranteed stable across releases.
      required: [code, message, stage, retryable]
      properties:
        code:
          type: string
          enum:
            [
              parsing_failed,
              chunking_failed,
              indexing_failed,
              timeout,
              unknown,
            ]
          description: Stable machine-readable failure code.
        message:
          type: string
          description: Human-readable error summary safe to show operators.
        stage:
          type: string
          description: Pipeline stage where the failure occurred.
        retryable:
          type: boolean
          description: Whether the client may retry the job (e.g. transient upstream errors).
        details:
          type: object
          additionalProperties: true
          description: Optional structured context (provider messages, inner codes). Shape may vary.

    # ---------- Health ----------
    HealthResponse:
      type: object
      description: |
        **Liveness / readiness** snapshot. Top-level **`status`** aggregates named dependencies (**`ok`**, **`degraded`**, **`down`**).
        Each dependency returns only **`status`**—no error message or diagnostic payload. Use this endpoint for synthetic checks:
        when **`status`** is not **`ok`**, treat dependent features (ingest, search, jobs) as at risk until **`status`** recovers.
      required: [status, timestamp, version, dependencies]
      properties:
        status:
          type: string
          enum: [ok, degraded, down]
          description: Overall API health derived from dependency checks.
        timestamp:
          type: string
          format: date-time
          description: When the health snapshot was taken (ISO 8601).
        version:
          type: string
          description: Deployed API release identifier.
        dependencies:
          type: object
          description: Per-dependency connectivity and readiness signals.
          required:
            [
              rest-api,
              jobs-processor,
              vector-db,
              documents-bucket,
              documents-processor,
            ]
          properties:
            rest-api:
              description: Self-check of the REST API process.
              allOf:
                - $ref: "#/components/schemas/DependencyStatus"
            jobs-processor:
              description: Asynchronous document and related job processing.
              allOf:
                - $ref: "#/components/schemas/DependencyStatus"
            vector-db:
              description: Vector / embedding store used for search.
              allOf:
                - $ref: "#/components/schemas/DependencyStatus"
            documents-bucket:
              description: Object storage for uploaded files.
              allOf:
                - $ref: "#/components/schemas/DependencyStatus"
            documents-processor:
              description: Document ingestion and conversion availability.
              allOf:
                - $ref: "#/components/schemas/DependencyStatus"

    DependencyStatus:
      type: object
      description: |
        Health of a single named dependency (storage, vector index, job execution, etc.). Only **`status`** is returned;
        failures are not described in the payload.
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, degraded, down]
          description: Health of this dependency in isolation.
