openapi: 3.0.3
info:
  title: SOAT Documents API
  version: 1.0.0
  description: API for managing AI-indexed text documents with semantic search
  contact:
    name: SOAT Team
    url: https://github.com/ttoss/soat
servers:
  - url: '{baseUrl}'
    description: Base URL of your SOAT deployment (e.g. https://your-soat.com or http://localhost:5047)
    variables:
      baseUrl:
        description: The base URL of your SOAT deployment
        default: http://localhost:5047
tags:
  - name: Documents
    description: Manage documents
security:
  - bearerAuth: []
paths:
  /api/v1/documents:
    get:
      tags:
        - Documents
      summary: List documents
      description: Returns all documents the caller has access to. If projectId is provided, returns only documents in that project. project keys are scoped to a single project automatically. JWT users without projectId receive documents across all their accessible projects.
      operationId: listDocuments
      parameters:
        - name: project_id
          in: query
          required: false
          description: Project ID (optional)
          schema:
            type: string
            example: 'proj_V1StGXR8Z5jdHi6B'
      responses:
        '200':
          description: List of documents
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DocumentRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags:
        - Documents
      summary: Create a document
      description: Creates a new text document and generates an embedding vector for semantic search. project keys automatically infer the project from the key's scope; JWT callers must supply projectId.
      operationId: createDocument
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content
              properties:
                project_id:
                  x-soat-ref: projects
                  type: string
                  description: Project ID. Required for JWT auth; omit when using an project key.
                  example: 'proj_V1StGXR8Z5jdHi6B'
                content:
                  type: string
                  example: 'The quick brown fox jumps over the lazy dog.'
                path:
                  type: string
                  description: Logical path within the project (e.g. /reports/q1.txt). Defaults to /filename if omitted.
                  example: '/reports/q1.txt'
                filename:
                  type: string
                  example: 'my-doc.txt'
                title:
                  type: string
                  description: Document title
                metadata:
                  type: object
                  description: >-
                    Arbitrary metadata object. Unlike other body fields, keys
                    are stored and returned verbatim in the casing supplied —
                    they are not converted between snake_case and camelCase.
                tags:
                  type: object
                  additionalProperties:
                    type: string
                  description: Key-value tags
                chunk_strategy:
                  type: string
                  enum: [page, whole, size]
                  description: How to split the content into embeddable chunks. `whole` (default) stores the content as a single chunk; `size` splits into fixed-size character windows with overlap. `page` is equivalent to `whole` for plain text.
                  default: whole
                chunk_size:
                  type: integer
                  description: Window size in characters when `chunk_strategy=size`. Defaults to 1000.
                  example: 1000
                chunk_overlap:
                  type: integer
                  description: Overlap in characters between consecutive windows when `chunk_strategy=size`. Defaults to 200.
                  example: 200
      responses:
        '201':
          description: Document created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/ingest:
    post:
      tags:
        - Documents
      summary: Ingest a file into a chunked document
      description: |
        Parses an already-uploaded file and creates one Document split into one or
        more embedded chunks. The source format is detected from the file's content
        type: PDFs are parsed page-by-page; `text/plain` and `text/markdown` files
        are read as a single source. How the source is chunked is controlled by
        `chunk_strategy`.
      operationId: ingestDocument
      x-iam-action: documents:IngestDocument
      parameters:
        - name: async
          in: query
          required: false
          description: When omitted or `true` (default), processing runs in the background and `202 Accepted` is returned immediately with `status=pending`. Pass `false` to run synchronously and receive `201 Created` with `status=ready`.
          schema:
            type: boolean
            default: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - file_id
              properties:
                file_id:
                  x-soat-ref: files
                  type: string
                  description: ID of the uploaded file. Must be one of application/pdf, text/plain, text/markdown.
                  example: 'file_V1StGXR8Z5jdHi6B'
                project_id:
                  x-soat-ref: projects
                  type: string
                  description: Project ID. Required for JWT auth; omit when using a project key.
                  example: 'proj_V1StGXR8Z5jdHi6B'
                path_prefix:
                  type: string
                  description: Path prefix under which to store the document (e.g. /docs/). The filename is appended automatically.
                  example: '/docs/'
                tags:
                  type: object
                  additionalProperties:
                    type: string
                  description: Key-value tags to attach to the document.
                chunk_strategy:
                  type: string
                  enum: [page, whole, size]
                  description: How to split the source into chunks. `page` (default) creates one chunk per non-empty page (PDF); for non-paged sources it yields a single chunk. `whole` joins everything into one chunk. `size` splits into fixed-size character windows with overlap.
                  default: page
                chunk_size:
                  type: integer
                  description: Window size in characters when `chunk_strategy=size`. Defaults to 1000.
                  example: 1000
                chunk_overlap:
                  type: integer
                  description: Overlap in characters between consecutive windows when `chunk_strategy=size`. Defaults to 200.
                  example: 200
      responses:
        '201':
          description: Ingestion completed synchronously (only when `?async=false`). The document is fully indexed and ready for search.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestedDocumentRecord'
        '202':
          description: Ingestion accepted. The document record has been created with `status=pending` and processing runs in the background. Poll `GET /api/v1/documents/{document_id}` until `status` is `ready` or `failed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestedDocumentRecord'
        '400':
          description: Invalid request, file not found, or unsupported content type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: The file is too large to ingest synchronously (`?async=false`). Retry in async mode and poll the document status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}:
    get:
      tags:
        - Documents
      summary: Get a document by ID
      description: Returns a document with its text content
      operationId: getDocument
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      responses:
        '200':
          description: Document found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags:
        - Documents
      summary: Delete a document
      description: Deletes a document and its underlying file
      operationId: deleteDocument
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      responses:
        '204':
          description: Document deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags:
        - Documents
      summary: Update a document
      description: Updates document content, title, path, metadata, or tags. Supplying `path` moves the document to a new logical path within the project.
      operationId: updateDocument
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content:
                  type: string
                  description: New text content
                title:
                  type: string
                  description: New title
                path:
                  type: string
                  nullable: true
                  description: Logical path within the project (e.g. /reports/q1.txt). Pass null to clear.
                  example: '/reports/q1.txt'
                metadata:
                  type: object
                  description: >-
                    Arbitrary metadata object. Unlike other body fields, keys
                    are stored and returned verbatim in the casing supplied —
                    they are not converted between snake_case and camelCase.
                tags:
                  type: object
                  additionalProperties:
                    type: string
                  description: Key-value tags
      responses:
        '200':
          description: Document updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/status:
    get:
      tags:
        - Documents
      summary: Get document ingestion status
      description: |
        Returns a lightweight ingestion status payload for polling — `status`,
        `chunk_count`, `total_pages`, and (when failed) `error`. Unlike
        `GET /documents/{document_id}`, it never returns the assembled chunk
        content, so it is cheap to poll on large documents. A document whose
        ingestion has stalled (no progress past the configured timeout) is
        transitioned to `failed` with `error=INGESTION_TIMEOUT` on read.
      operationId: getDocumentStatus
      x-iam-action: documents:GetDocument
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      responses:
        '200':
          description: Document ingestion status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentStatusRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/ingest:
    post:
      tags:
        - Documents
      summary: Re-ingest an existing document
      description: |
        Re-runs ingestion for an existing document against its already-stored
        source file. Existing chunks are discarded and the document is reset to
        `status=pending` before re-processing. Use this to recover a document
        stuck in `processing`/`failed` or to re-chunk with a different strategy
        without re-uploading the file. Async by default (`202`); pass
        `?async=false` to run synchronously (`201`).
      operationId: reingestDocument
      x-iam-action: documents:IngestDocument
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
        - name: async
          in: query
          required: false
          description: When omitted or `true` (default), processing runs in the background and `202 Accepted` is returned immediately with `status=pending`. Pass `false` to run synchronously and receive `201 Created` with `status=ready`.
          schema:
            type: boolean
            default: true
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                chunk_strategy:
                  type: string
                  enum: [page, whole, size]
                  description: How to split the source into chunks. Defaults to `page`.
                  default: page
                chunk_size:
                  type: integer
                  description: Window size in characters when `chunk_strategy=size`. Defaults to 1000.
                  example: 1000
                chunk_overlap:
                  type: integer
                  description: Overlap in characters between consecutive windows when `chunk_strategy=size`. Defaults to 200.
                  example: 200
      responses:
        '201':
          description: Re-ingestion completed synchronously (only when `?async=false`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestedDocumentRecord'
        '202':
          description: Re-ingestion accepted. The document was reset to `status=pending` and processing runs in the background. Poll `GET /api/v1/documents/{document_id}/status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestedDocumentRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: The file is too large to re-ingest synchronously (`?async=false`). Retry in async mode.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/ingestion-callback:
    post:
      tags:
        - Documents
      summary: Deliver an async converter result
      description: |
        Token-authed callback for a tool converter that deferred conversion by
        returning `{ "status": "pending" }` (see the Ingestion Rules module
        docs). Not IAM-gated — the external converter is not a SOAT principal,
        so it authenticates with the single-use token minted for this
        document and ingestion attempt (delivered as `callback.token` /
        embedded in `callback.url` in the original converter invocation).
        Accepted only while the document is still awaiting that exact
        attempt; rejected with `409` if the attempt already completed, timed
        out, or was superseded by a re-ingest.
      operationId: completeIngestionCallback
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
        - name: token
          in: query
          required: true
          description: Single-use signed token from the original `callback.token`
          schema:
            type: string
      requestBody:
        required: true
        description: >-
          The converter output contract, adapted for a JSON request body: a
          single page as `{ text }`, or `{ pages: [{ text, page_number }] }`
          for multiple pages.
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [text]
                  properties:
                    text:
                      type: string
                - type: object
                  required: [pages]
                  properties:
                    pages:
                      type: array
                      items:
                        type: object
                        properties:
                          text:
                            type: string
                          page_number:
                            type: integer
      responses:
        '204':
          description: >-
            Conversion completed — the document was chunked and marked
            `ready` (or `failed` with `FILE_PARSE_FAILED` if the output
            produced no text).
        '401':
          description: The token is missing, invalid, or does not match this document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            The document is no longer awaiting this conversion attempt
            (already completed, timed out, or superseded by a re-ingest).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: The output shape is unrecognized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/tags:
    get:
      tags:
        - Documents
      summary: Get document tags
      description: Returns all tags attached to the document
      operationId: getDocumentTags
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      responses:
        '200':
          description: Document tags
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      tags:
        - Documents
      summary: Replace document tags
      description: Replaces all tags on the document with the provided tags (not merged)
      operationId: replaceDocumentTags
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties:
                type: string
              example:
                category: research
                status: draft
      responses:
        '200':
          description: Tags replaced
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags:
        - Documents
      summary: Merge document tags
      description: Merges provided tags with existing tags (existing tags are preserved unless overridden)
      operationId: mergeDocumentTags
      parameters:
        - name: document_id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            example: 'doc_V1StGXR8Z5jdHi6B'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties:
                type: string
              example:
                priority: high
      responses:
        '200':
          description: Tags merged
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: JWT token or sk_ api key
  schemas:
    DocumentRecord:
      type: object
      properties:
        id:
          type: string
          description: Document ID
          example: 'doc_V1StGXR8Z5jdHi6B'
        file_id:
          x-soat-ref: files
          type: string
          description: Underlying file ID
          example: 'file_V1StGXR8Z5jdHi6B'
        project_id:
          x-soat-ref: projects
          type: string
          description: Project ID
          example: 'proj_V1StGXR8Z5jdHi6B'
        path:
          type: string
          nullable: true
          description: Logical path of the document within the project (e.g. /reports/q1.txt)
          example: '/reports/q1.txt'
        filename:
          type: string
          description: Original filename
          example: 'my-doc.txt'
        size:
          type: integer
          description: File size in bytes
          example: 42
        status:
          type: string
          enum: [pending, processing, ready, failed]
          description: Ingestion lifecycle state. `pending` — enqueued; `processing` — chunks being extracted and embedded; `ready` — fully indexed; `failed` — processing error (see `metadata.failure_reason`).
          example: 'ready'
        content:
          type: string
          nullable: true
          description: Text content (only present on getDocument, and only when status is ready)
          example: 'The quick brown fox jumps over the lazy dog.'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    IngestedDocumentRecord:
      allOf:
        - $ref: '#/components/schemas/DocumentRecord'
        - type: object
          properties:
            chunk_count:
              type: integer
              description: Number of chunks created from the file.
              example: 10
    DocumentStatusRecord:
      type: object
      properties:
        id:
          type: string
          description: Document ID
          example: 'doc_V1StGXR8Z5jdHi6B'
        status:
          type: string
          enum: [pending, processing, ready, failed]
          description: Ingestion lifecycle state.
          example: 'ready'
        chunk_count:
          type: integer
          description: Number of chunks **currently indexed** for this document (a live count). Grows while `status=processing` and equals the final total once `ready`; `0` while `pending`.
          example: 10
        total_chunks:
          type: integer
          nullable: true
          description: Planned total number of chunks, known once chunking begins. `null` until then. Used as the denominator for `progress`.
          example: 12
        total_pages:
          type: integer
          nullable: true
          description: Number of source pages extracted. Only known after extraction, so it is `null` until `status` is `ready` or `failed` (not the same as zero pages).
          example: 12
        progress:
          type: integer
          nullable: true
          description: Ingestion progress as a percentage (`chunk_count / total_chunks`). `0` while `pending`, climbs while `processing` (capped at 99), `100` when `ready`, and `null` when `failed` or not yet computable.
          example: 100
        error:
          type: string
          nullable: true
          description: Failure reason when `status` is `failed` (e.g. `FILE_PARSE_FAILED`, `INGESTION_TIMEOUT`).
          example: 'INGESTION_TIMEOUT'
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          example: 'Document not found'
