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'
        - name: path_prefix
          in: query
          required: false
          description: >-
            Only documents filed under this directory. The prefix is a path
            boundary, not a substring: `/reports` returns `/reports/q1.txt` and
            never `/reports-archive/q1.txt`, and `/` selects the whole project.
            A leading slash is optional and a trailing one is ignored, so
            `reports`, `/reports` and `/reports/` are the same filter. `%` and
            `_` are literal characters, not wildcards.
          schema:
            type: string
            example: '/reports/'
        - name: include_withdrawn
          in: query
          required: false
          description: >-
            Include withdrawn documents. A withdrawn document leaves every
            default read and is not in the knowledge index at all — its chunks
            are dropped when it is withdrawn — so this shows it in the listing
            but never in a search.
          schema:
            type: boolean
            default: false
        - name: related_to
          in: query
          description: >-
            Only documents related to this one, on either side of the edge:
            what it points at, and what points at it. An id with no relations
            narrows the listing to nothing.
          schema:
            type: string
            example: doc_V1StGXR8Z5jdHi6B
        - $ref: './tags.yaml#/components/parameters/TagsQuery'
        - $ref: './metadata-filters.yaml#/components/parameters/MetadataQuery'
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: List of documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentRecord'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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
              additionalProperties: false
              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>`, or to `/<document_id>.txt` when
                    neither is given — a document with no path is reachable
                    only by its id, since a prefix filter never matches null.
                  example: '/reports/q1.txt'
                filename:
                  type: string
                  example: 'my-doc.txt'
                title:
                  type: string
                  description: Document title
                metadata:
                  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.
                  allOf:
                    - $ref: './metadata.yaml#/components/schemas/MetadataBag'
                tags:
                  $ref: './tags.yaml#/components/schemas/TagBag'
                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: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `path` is held by another file in the project (`NAME_CONFLICT`),
            or the project's `storage_bytes` quota is exceeded
            (`QUOTA_STORAGE_EXCEEDED`). Delete stored content, or raise the
            quota — no window reset clears a stored total, so no `Retry-After`
            is sent.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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`.

        A file can only back one Document — a second call with the same `file_id`
        returns `409 FILE_ALREADY_INGESTED`. To re-process an already-ingested file
        (e.g. with a different `chunk_strategy`), use
        `POST /documents/{document_id}/ingest`; to ingest the same source under a
        different path, upload a new copy of the file first.
      operationId: ingestDocument
      x-iam-action: documents:IngestDocument
      parameters:
        - name: wait
          in: query
          required: false
          description: When omitted or `false` (default), processing runs in the background and `202 Accepted` is returned immediately with `status=pending`. Pass `true` to block until processing completes and receive `201 Created` with `status=ready`.
          schema:
            type: boolean
            default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - file_id
              additionalProperties: false
              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:
                  $ref: './tags.yaml#/components/schemas/TagBag'
                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
                session_id:
                  x-soat-ref: sessions
                  type: string
                  description: Session the ingest runs for. The converter's generation and the chunk embeddings are metered against the session and its actor.
                  example: 'sess_V1StGXR8Z5jdHi6B'
                actor_id:
                  x-soat-ref: actors
                  type: string
                  description: Actor the ingest runs for when there is no session; metered like `session_id`. Beside `session_id` it must be that session's actor, else `400 VALIDATION_FAILED`.
                  example: 'actor_V1StGXR8Z5jdHi6B'
      responses:
        '201':
          description: Ingestion completed synchronously (only when `?wait=true`). 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, unsupported content type, or an unknown `session_id` (`SESSION_NOT_FOUND`) or `actor_id` (`ACTOR_NOT_FOUND`) in the project
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: The file already backs a Document (a file can only be ingested once — use `POST /documents/{document_id}/ingest` to re-process the existing document, or upload a new copy of the file to ingest it separately), the path it would be filed at is held by another file in the project (`NAME_CONFLICT`), or the project's `storage_bytes` quota is exceeded (`QUOTA_STORAGE_EXCEEDED`; delete stored content or raise the quota — no `Retry-After` is sent, since no window reset clears a stored total).
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '413':
          description: The file is too large to ingest synchronously (`?wait=true`). Retry in background mode and poll the document status.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
  /api/v1/documents/export:
    get:
      tags:
        - Documents
      summary: Export documents as NDJSON
      description: >-
        Streams a project's documents as newline-delimited JSON — one document
        object per line, oldest first — for archiving the corpus or shipping it
        into another system. `project_id` is required: the export is
        per-project by design. The rows are the rows the listing returns for
        the same caller, so a policy that hides a document hides it here too,
        and withdrawn documents and the reserved `/.system/` root are left out.
      operationId: exportDocuments
      # Not a tool: the response is a stream with no JSON projection, and an
      # export is deliberately unbounded, so the callable form of "read the
      # documents" is `listDocuments`, which is paged.
      x-soat-mcp-exclude: true
      parameters:
        - name: project_id
          in: query
          required: true
          description: Project whose documents are exported
          schema:
            type: string
            example: proj_V1StGXR8Z5jdHi6B
        - name: path_prefix
          in: query
          description: >-
            Only documents filed under this directory. The prefix is a path
            boundary, not a substring, exactly as on the listing.
          schema:
            type: string
            example: '/reports'
      responses:
        '200':
          description: >-
            A newline-delimited stream of documents. Each line is a JSON object
            with the same fields as `Document`.
          content:
            application/x-ndjson:
              schema:
                type: string
        '400':
          description: '`project_id` is required'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
  /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: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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'
        - $ref: './concurrency.yaml#/components/parameters/IfMatchVersion'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              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:
                  description: >-
                    Arbitrary metadata object, replacing the stored bag; `null`
                    clears it. Unlike other body fields, keys are stored and
                    returned verbatim in the casing supplied — they are not
                    converted between snake_case and camelCase.
                  allOf:
                    - $ref: './metadata.yaml#/components/schemas/NullableMetadataBag'
                tags:
                  $ref: './tags.yaml#/components/schemas/TagBag'
                expected_version:
                  description: >-
                    Refuses the write unless the document is at this version.
                  allOf:
                    - $ref: './concurrency.yaml#/components/schemas/ExpectedVersion'
      responses:
        '200':
          description: Document updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: >-
            The document has moved past the version this write read
            (`VERSION_CONFLICT`, with `meta.current_version`), or `path` is held
            by another file in the project (`NAME_CONFLICT`). Nothing is
            written.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/relations:
    get:
      tags:
        - Documents
      summary: List a document's relations
      description: >-
        Returns the typed edges this document asserts about others, oldest
        first. An edge is owned by the document it leaves, so this is what the
        document claims — use `?related_to=` on the listing to find what
        claims something about it.
      operationId: listDocumentRelations
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
            example: doc_V1StGXR8Z5jdHi6B
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: The document's relations, oldest first
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentRelation'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Document not found
    post:
      tags:
        - Documents
      summary: Assert a relation
      description: >-
        Asserts a typed edge from this document to another in the same project.
        Asserting the same edge twice is `409`: an edge is a fact, and the
        second assertion is the same fact.


        Writing an edge is a write of the asserting document
        (`documents:UpdateDocument`); the document it points at is unchanged,
        which is what lets an agent record what its own report derives from
        without being able to make another report claim anything.
      operationId: createDocumentRelation
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
            example: doc_V1StGXR8Z5jdHi6B
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - type
                - to_document_id
              properties:
                type:
                  type: string
                  enum: [derived_from, supersedes, cites]
                  description: What this document claims about the other
                  example: cites
                to_document_id:
                  type: string
                  description: >-
                    The document the edge points at. Must be in the same
                    project, and visible to the caller.
                  example: doc_9Kp2mQxZ7bT4rN6W
      responses:
        '201':
          description: The asserted relation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRelation'
        '400':
          description: >-
            An undeclared `type`, a `to_document_id` that is not a document id,
            a document relating to itself, or a target in another project
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Either document was not found
        '409':
          description: That relation is already asserted
  /api/v1/documents/{document_id}/relations/{relation_id}:
    delete:
      tags:
        - Documents
      summary: Retract a relation
      description: >-
        Removes an edge this document asserts. Both documents are left as they
        are — retracting a claim is not a change to what it was about.
      operationId: deleteDocumentRelation
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
            example: doc_V1StGXR8Z5jdHi6B
        - name: relation_id
          in: path
          required: true
          schema:
            type: string
            example: doc_rel_V1StGXR8Z5jdHi6B
      responses:
        '204':
          description: Relation retracted
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Document or relation not found
  /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: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
  /api/v1/documents/{document_id}/withdraw:
    post:
      tags:
        - Documents
      summary: Withdraw a document
      description: >
        Takes a document out of every default read while keeping its history.
        The withdrawal is archived as a **tombstone version**, so one mechanism
        answers what a document holds now and there is no second lifecycle flag
        for a reader to miss.


        Its chunks are dropped from the knowledge index, so a withdrawn
        document costs a live search nothing. The content is not lost with
        them: it is in the version before the tombstone, which
        [`POST /api/v1/documents/{document_id}/versions/{version}/restore`](/docs/api/documents/restore-document-version)
        re-chunks from.


        `DELETE` stays what it is — permanent, and it removes the backing file.
        Withdrawal is the reversible act. It does not apply under `/.system/`:
        a platform-written document keeps the owning module's lifecycle.
      operationId: withdrawDocument
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
        - $ref: './concurrency.yaml#/components/parameters/IfMatchVersion'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                version_label:
                  type: string
                  nullable: true
                  description: >-
                    Optional tag for the tombstone version this write archives.
                  example: superseded-by-q2
                expected_version:
                  description: >-
                    Refuses the withdrawal unless the document is at this
                    version.
                  allOf:
                    - $ref: './concurrency.yaml#/components/schemas/ExpectedVersion'
      responses:
        '200':
          description: The withdrawn document
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '400':
          description: Bad Request — the document is filed under the reserved root
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: >-
            The document is already withdrawn (`DOCUMENT_ALREADY_WITHDRAWN`),
            or it has moved past the version this write read
            (`VERSION_CONFLICT`, with `meta.current_version`).
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/documents/{document_id}/versions:
    get:
      tags:
        - Documents
      summary: List a document's content versions
      description: >
        Returns the document's archived states, newest first. A version is
        written on create and on every subsequent write that changes the
        content or its annotations, and a withdrawal is archived as a version
        carrying no content.
      operationId: listDocumentVersions
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: List of document versions, newest first
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentVersion'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/documents/{document_id}/versions/{version}:
    get:
      tags:
        - Documents
      summary: Fetch an archived document version
      description: >
        Returns the exact content and annotations the document held at a given
        version, which is what lets a run that cited a version read what it
        read.
      operationId: getDocumentVersion
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
        - name: version
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: Archived document version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentVersion'
        '400':
          description: Bad Request — version is not a positive integer
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/documents/{document_id}/versions/{version}/restore:
    post:
      tags:
        - Documents
      summary: Restore an archived document version
      description: >
        Writes an archived version's content and annotations back as the
        document's live state, which archives them again as a **new** version
        rather than rewinding the counter — so a run citing any version in
        between still resolves.


        The restore runs through the ordinary update path, so the content is
        re-chunked and re-embedded; restoring the state the document already
        holds is a no-op and archives nothing. Restoring any content version of
        a withdrawn document brings it back into listings and search.


        A tombstone version has no content, so naming one is
        `400 VALIDATION_FAILED`: restore the version before it instead.
      operationId: restoreDocumentVersion
      parameters:
        - name: document_id
          in: path
          required: true
          schema:
            type: string
        - name: version
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                label:
                  type: string
                  nullable: true
                  description: >-
                    Optional tag for the version this restore archives.
                    Defaults to `restored from v<version>`.
                  example: reinstated
      responses:
        '200':
          description: The document, at its new version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentRecord'
        '400':
          description: Bad Request — invalid version, or the version is a withdrawal
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document or version not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: >-
            Another write took the document's version first
            (`VERSION_CONFLICT`, with `meta.current_version`), or the restored
            `path` is held by another file in the project (`NAME_CONFLICT`).
            Nothing is written.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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. Background by default (`202`); pass
        `?wait=true` 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: wait
          in: query
          required: false
          description: When omitted or `false` (default), processing runs in the background and `202 Accepted` is returned immediately with `status=pending`. Pass `true` to block until processing completes and receive `201 Created` with `status=ready`.
          schema:
            type: boolean
            default: false
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              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
                session_id:
                  x-soat-ref: sessions
                  type: string
                  description: Session the ingest runs for. The converter's generation and the chunk embeddings are metered against the session and its actor.
                  example: 'sess_V1StGXR8Z5jdHi6B'
                actor_id:
                  x-soat-ref: actors
                  type: string
                  description: Actor the ingest runs for when there is no session; metered like `session_id`. Beside `session_id` it must be that session's actor, else `400 VALIDATION_FAILED`.
                  example: 'actor_V1StGXR8Z5jdHi6B'
      responses:
        '201':
          description: Re-ingestion completed synchronously (only when `?wait=true`).
          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: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '409':
          description: >-
            The project's `storage_bytes` quota is exceeded
            (`QUOTA_STORAGE_EXCEEDED`). Delete stored content, or raise the
            quota — no window reset clears a stored total, so no `Retry-After`
            is sent.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '413':
          description: The file is too large to re-ingest synchronously (`?wait=true`). Retry in background mode.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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]
                  additionalProperties: false
                  properties:
                    text:
                      type: string
                - type: object
                  required: [pages]
                  additionalProperties: false
                  properties:
                    pages:
                      type: array
                      items:
                        type: object
                        additionalProperties: false
                        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: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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: './errors.yaml#/components/schemas/ErrorResponse'
        '422':
          description: The output shape is unrecognized.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/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:
              $ref: './tags.yaml#/components/schemas/TagBag'
      responses:
        '200':
          description: Tags replaced
          content:
            application/json:
              schema:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
    patch:
      tags:
        - Documents
      summary: Merge document tags
      description: Merges provided tags into the document's 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:
              $ref: './tags.yaml#/components/schemas/TagBag'
      responses:
        '200':
          description: Tags merged
          content:
            application/json:
              schema:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '404':
          description: Document not found
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: JWT token or sk_ api key
  schemas:
    DocumentVersion:
      type: object
      description: >-
        An immutable archive of a document's content and annotations at one
        version.
      properties:
        id:
          type: string
          description: Public ID of the archived version
          example: doc_ver_V1StGXR8Z5jdHi6B
        document_id:
          x-soat-ref: documents
          type: string
          description: Public ID of the document this version belongs to
          example: doc_V1StGXR8Z5jdHi6B
        version:
          type: integer
          description: The archived version number
          example: 1
        config:
          type: object
          additionalProperties: true
          description: >-
            The document's versioned surface as it stood at this version: its
            `content`, `title`, `path`, `metadata`, `tags` and chunk
            configuration. A full snapshot rather than a diff, because what a
            restore has to reproduce is what a run read — which is a read by
            version, and a diff chain would have to be replayed to answer it.


            A withdrawal is archived as a version too, carrying `withdrawn:
            true` and no content. Restoring one is refused; restore the version
            before it.


            Deliberately open rather than a fixed schema: an archive written by
            an earlier release of SOAT reflects the document surface **of its
            own time**, so it may carry fields the current API no longer
            documents.
        label:
          type: string
          nullable: true
          description: >-
            Optional human tag for this version, e.g. `pre-correction`. Set from
            the `version_label` of the write that archived it, or generated for
            a restore or a withdrawal.
          example: pre-correction
        created_by_principal_type:
          nullable: true
          allOf:
            - $ref: './principals.yaml#/components/schemas/PrincipalType'
          description: >-
            Who produced this version. Null for a write with no principal behind
            it.
        created_by_principal_id:
          type: string
          nullable: true
          description: >-
            Public id of that principal: the user (`user_…`), or for API key
            auth the key itself (`key_…`), so the record names which key acted.
          example: user_V1StGXR8Z5jdHi6B
        created_at:
          type: string
          format: date-time

    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'
        content_type:
          type: string
          description: Media type of the source file the document was ingested from. Absent when the underlying file is gone.
          example: 'application/pdf'
        size:
          type: integer
          description: File size in bytes
          example: 42
        status:
          type: string
          enum: [pending, processing, ready, failed, withdrawn]
          description: Ingestion lifecycle state. `pending` — enqueued; `processing` — chunks being extracted and embedded; `ready` — fully indexed; `failed` — processing error (see the `error` field on `GET /documents/{id}/status`); `withdrawn` — taken out of listings and search, with its chunks dropped, and restorable from an earlier version.
          example: 'ready'
        version:
          type: integer
          description: >-
            The document's content version, starting at 1. Every write that
            changes the content or its annotations archives the state it
            replaced and increments this.
          example: 1
        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.'
        chunk_strategy:
          type: string
          enum: [page, whole, size]
          description: The chunk strategy the document was last (re-)ingested with. Absent when the default (`whole`) was used — the mapper omits the key rather than sending `null`.
          example: 'size'
        chunk_size:
          type: integer
          description: Window size in characters used when `chunk_strategy=size`. Absent otherwise.
          example: 800
        chunk_overlap:
          type: integer
          description: Overlap in characters between consecutive windows used when `chunk_strategy=size`. Absent otherwise.
          example: 120
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DocumentRelation:
      type: object
      description: >-
        A typed edge one document asserts about another, within one project.
      properties:
        id:
          type: string
          example: doc_rel_V1StGXR8Z5jdHi6B
        type:
          type: string
          enum: [derived_from, supersedes, cites]
          description: >-
            What the asserting document claims: it was `derived_from` the
            other, `supersedes` it, or `cites` it.
          example: cites
        from_document_id:
          type: string
          description: The document that asserts the edge
          example: doc_V1StGXR8Z5jdHi6B
        to_document_id:
          type: string
          description: The document the edge points at
          example: doc_9Kp2mQxZ7bT4rN6W
        created_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:
          allOf:
            - $ref: './errors.yaml#/components/schemas/StoredError'
          nullable: true
          description: >-
            Why the ingestion failed, when `status` is `failed`; its `code` is
            for example `FILE_PARSE_FAILED` or `INGESTION_TIMEOUT`.
          example:
            code: INGESTION_TIMEOUT
            message: Ingestion stalled and was marked failed.
