openapi: 3.1.0
info:
  title: iDMS API
  version: "2.0"
  description: |
    Intelligent document management for Swiss property management.

    Human-readable reference with examples, the German enum glossary and the
    full classification model lives at `/docs` (source: `docs/API_V2.md`).

    Conventions:
    - Every response uses the envelope `{ data, meta, error }`.
    - All endpoints require a Bearer API key and are rate-limited to
      100 requests/minute per key (`X-RateLimit-*` headers on every response).
    - The API never returns `403` for cross-org access — out-of-org documents are a
      `404`, indistinguishable from a missing resource.
  contact:
    name: mory.ai
    url: https://idms.mory.ai/docs
  license:
    name: Proprietary
    url: https://mory.ai
servers:
  - url: https://idms.mory.ai
security:
  - bearerAuth: []

paths:
  /api/v2/documents:
    get:
      operationId: listDocuments
      summary: List documents
      tags: [Documents — read]
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: doc_type, in: query, description: "Exact match on the legacy doc_type (e.g. invoice).", schema: { type: string } }
        - { name: subtype, in: query, description: "Exact match on the current-model doc_subtype.", schema: { type: string } }
        - { name: intent, in: query, description: "Exact match on the current-model intent.", schema: { type: string } }
        - { name: status, in: query, description: "legacy pipeline status (e.g. completed).", schema: { type: string } }
        - { name: zahlungsstatus, in: query, schema: { type: string, enum: [offen, bezahlt, teilbezahlt, ueberfaellig, sonstiges] } }
        - { name: bereich, in: query, description: "Current-model Bereich key.", schema: { type: string } }
        - { name: tag, in: query, description: "Tag key; repeatable for OR semantics.", schema: { type: string }, explode: true }
        - { name: betrag_min, in: query, description: "Minimum extracted total amount.", schema: { type: number } }
        - { name: betrag_max, in: query, description: "Maximum extracted total amount.", schema: { type: number } }
        - { name: faellig_after, in: query, description: "Due date on/after this date (YYYY-MM-DD).", schema: { type: string, format: date } }
        - { name: faellig_before, in: query, description: "Due date on/before this date (YYYY-MM-DD).", schema: { type: string, format: date } }
        - { name: external_id, in: query, description: "Exact lookup by your external_id (idempotency key).", schema: { type: string } }
        - { name: updated_since, in: query, description: "Documents with updated_at after this ISO-8601 timestamp, ordered oldest-first (delta sync); pair with meta.server_time as the next cursor. Malformed value → 400.", schema: { type: string, format: date-time } }
        - { name: profile, in: query, description: "Output profile key (provisioned per account).", schema: { type: string } }
      responses:
        "200":
          description: Paginated document list.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Document" } }
                  meta: { $ref: "#/components/schemas/PageMeta" }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: bulkPatchDocuments
      summary: Bulk-update classification on up to 100 documents
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  maxItems: 100
                  items:
                    allOf:
                      - type: object
                        required: [id]
                        properties: { id: { type: string, format: uuid } }
                      - { $ref: "#/components/schemas/ClassificationPatch" }
                reason: { type: string, description: "Audit-log reason." }
      responses:
        "200":
          description: Per-item results; partial failures do not abort the batch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        status: { type: string, enum: [ok, error] }
                        error: { type: [string, "null"] }
                        doc:
                          description: Full updated detail on success; absent on error.
                          oneOf: [{ $ref: "#/components/schemas/DocumentDetail" }, { type: "null" }]
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
                      ok: { type: integer }
                      error: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnknownTaxonomyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getDocument
      summary: Get one document (full detail)
      tags: [Documents — read]
      parameters:
        - { name: profile, in: query, description: "Output profile key; rewrites the response shape.", schema: { type: string } }
      responses:
        "200":
          description: Full document with classification, metadata, contacts and linked entities.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/DocumentDetail" }
                  meta:
                    type: [object, "null"]
                    properties:
                      profile: { type: object }
                      missing_required_fields: { type: array, items: { type: string } }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: patchDocument
      summary: Update classification fields on one document
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/ClassificationPatch" }
                - type: object
                  properties:
                    reason: { type: string, description: "Audit-log reason." }
      responses:
        "200": { $ref: "#/components/responses/DocumentDetailResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnknownTaxonomyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}/classification:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: patchClassification
      summary: Update classification (alias of PATCH /documents/:id)
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/ClassificationPatch" }
                - type: object
                  properties:
                    reason: { type: string }
      responses:
        "200": { $ref: "#/components/responses/DocumentDetailResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnknownTaxonomyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}/metadata:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: patchMetadata
      summary: Merge fields into extracted_metadata
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >
                Arbitrary JSON keys are merged into extracted_metadata.
                `reason` is consumed for the audit log, not stored.
              additionalProperties: true
              properties:
                reason: { type: string }
      responses:
        "200": { $ref: "#/components/responses/DocumentDetailResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}/contacts:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getDocumentContacts
      summary: List contacts linked to a document
      description: >
        Contact ids are org-level and may disappear when duplicates are merged —
        treat document_id as the stable key and re-fetch rather than caching
        contact_id long-term.
      tags: [Documents — read]
      responses:
        "200":
          description: Normalized Person/Firma contacts with their role on this document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Contact" } }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: patchDocumentContacts
      summary: Replace the document's contact links
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  items:
                    type: object
                    required: [contact_id, role]
                    properties:
                      contact_id: { type: string, format: uuid }
                      role: { type: string, enum: [absender, empfaenger, cc] }
                reason: { type: string }
      responses:
        "200":
          description: Updated contacts array (same shape as GET).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Contact" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}/tags:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: addTags
      summary: Add tags from the controlled taxonomy
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tags]
              properties:
                tags: { type: array, items: { type: string } }
      responses:
        "200": { $ref: "#/components/responses/TagsResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnknownTaxonomyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: removeTags
      summary: Remove tags
      tags: [Documents — write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tags]
              properties:
                tags: { type: array, items: { type: string } }
      responses:
        "200": { $ref: "#/components/responses/TagsResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/{id}/download:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getDownloadUrl
      summary: Get a short-lived signed download URL
      description: File bytes never travel through the API — follow `url` directly to storage.
      tags: [Documents — read]
      parameters:
        - { name: ttl, in: query, description: "Seconds; clamped to [60, 86400].", schema: { type: integer, default: 3600 } }
      responses:
        "200":
          description: Signed URL plus file metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, format: uri }
                      expires_at: { type: string, format: date-time }
                      filename: { type: string }
                      content_type: { type: string }
                      file_size: { type: integer }
                  meta:
                    type: object
                    properties:
                      ttl_seconds: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/upload:
    post:
      operationId: uploadDocument
      summary: Upload a single file or a ZIP container (multipart, ≤ 4 MB)
      description: >
        Max 4 MB per request (serverless body limit) — use the presigned flow
        (`/upload/init` + `/upload/finalize`) for files up to 250 MB.
        A `.zip` file is treated as a container: each entry becomes its own
        document with per-entry idempotency and per-entry accept/reject results.
        Microsoft Office lock files ("~$…") are rejected with 400.
      tags: [Documents — upload]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                external_id: { type: string, description: "Caller's stable id — primary idempotency key." }
                metadata: { type: string, description: "JSON string stored on the document." }
                force: { type: string, enum: ["true"], description: "Testing aid — bypass content-hash idempotency and process as a new document (stored without a content hash; cannot reuse an existing external_id)." }
      responses:
        "200":
          description: Created document, idempotent replay, or per-entry ZIP results.
          content:
            application/json:
              schema:
                oneOf:
                  - title: Single file
                    type: object
                    properties:
                      data: { $ref: "#/components/schemas/UploadedDocument" }
                      meta: { $ref: "#/components/schemas/IdempotencyMeta" }
                  - title: ZIP container
                    type: object
                    properties:
                      data:
                        type: object
                        properties:
                          container_kind: { type: string, const: zip }
                          container_filename: { type: string }
                          items: { type: array, items: { $ref: "#/components/schemas/ZipItem" } }
                      meta:
                        type: object
                        properties:
                          container_kind: { type: string, const: zip }
                          total_entries: { type: integer }
                          accepted: { type: integer }
                          idempotent_replay: { type: integer }
                          rejected: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422":
          description: ZIP container not readable, or over 50 entries.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/upload/init:
    post:
      operationId: presignedUploadInit
      summary: "Presigned upload step 1: get a signed PUT URL (≤ 250 MB)"
      tags: [Documents — upload]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, file_size, mime_type]
              properties:
                filename: { type: string }
                file_size: { type: integer, description: "Bytes; max 250 MB." }
                mime_type: { type: string }
                external_id: { type: string }
      responses:
        "200":
          description: Signed PUT URL (or idempotent replay of an existing document).
          content:
            application/json:
              schema:
                oneOf:
                  - title: New upload
                    type: object
                    properties:
                      data:
                        type: object
                        properties:
                          upload_url: { type: string, format: uri }
                          upload_token: { type: string }
                          storage_path: { type: string }
                          expires_at: { type: string, format: date-time }
                      meta: { type: object, properties: { idempotent_replay: { type: boolean, const: false } } }
                  - title: Idempotent replay
                    type: object
                    properties:
                      data: { $ref: "#/components/schemas/UploadedDocument" }
                      meta: { $ref: "#/components/schemas/IdempotencyMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/upload/finalize:
    post:
      operationId: presignedUploadFinalize
      summary: "Presigned upload step 2: register the uploaded file"
      tags: [Documents — upload]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [storage_path, filename, mime_type, file_size]
              properties:
                storage_path: { type: string, description: "Exactly what /upload/init returned." }
                filename: { type: string }
                mime_type: { type: string }
                file_size: { type: integer }
                external_id: { type: string }
                content_hash: { type: string, description: "SHA-256 hex, client-computed; enables content dedup." }
                force: { type: boolean, description: "Testing aid — bypass content-hash idempotency (row stored without a content hash)." }
                metadata: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Created document or idempotent replay.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/UploadedDocument" }
                  meta: { $ref: "#/components/schemas/IdempotencyMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: storage_path not scoped to the caller's organization.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422":
          description: Storage object not found at storage_path (PUT incomplete or wrong path).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/documents/upload/batch:
    post:
      operationId: uploadBatch
      summary: Upload up to 50 files in one multipart request
      tags: [Documents — upload]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: ["files[]"]
              properties:
                "files[]": { type: array, items: { type: string, format: binary }, maxItems: 50 }
                "external_ids[]": { type: array, items: { type: string }, description: "Paired index-wise with files[]." }
                "metadata[]": { type: array, items: { type: string }, description: "JSON string per file, paired index-wise." }
      responses:
        "200":
          description: Per-file results; partial failures do not abort the batch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        status: { type: string, enum: [created, idempotent_replay, failed] }
                        doc: { $ref: "#/components/schemas/UploadedDocument" }
                        error: { type: [string, "null"] }
                        dedup_by: { type: string, enum: [external_id, content_hash] }
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
                      created: { type: integer }
                      idempotent_replay: { type: integer }
                      failed: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v2/feedback:
    post:
      operationId: postFeedback
      summary: Report classification feedback (corrected field or rejected AI draft)
      description: >-
        Intake only — feedback never mutates the document or its
        classification. Use event_type "classification.changed" with
        field_path/old_value/new_value when your user corrected a field, or
        "ai_rejected" (no field detail needed) when your user deleted the AI
        draft entirely.
      tags: [Feedback]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, event_type]
              properties:
                document_id: { type: string, format: uuid, description: "iDMS document id; must belong to your organization." }
                event_type: { type: string, enum: [classification.changed, ai_rejected] }
                field_path: { type: [string, "null"], maxLength: 200, description: "Required for classification.changed." }
                old_value: { type: [string, "null"], maxLength: 2000 }
                new_value: { type: [string, "null"], maxLength: 2000 }
      responses:
        "200":
          description: Feedback stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      received: { type: boolean }
        "400":
          description: Validation failed (details in error).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "404":
          description: Document not found in your organization.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
  /api/v2/taxonomy:
    get:
      operationId: getTaxonomy
      summary: Full classification taxonomy (Bereiche, types, subtypes, intents, tag groups, tags)
      tags: [Taxonomy]
      responses:
        "200":
          description: The complete controlled vocabulary with DE/EN labels.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      bereiche: { type: array, items: { type: object, properties: { key: { type: string }, label_de: { type: string }, label_en: { type: string }, sort: { type: integer } } } }
                      doc_types: { type: array, items: { type: object, properties: { key: { type: string }, bereich_key: { type: string }, label_de: { type: string }, label_en: { type: string }, v1_projection: { type: string } } } }
                      doc_subtypes: { type: array, items: { type: object, properties: { key: { type: string }, doc_type_key: { type: string }, label_de: { type: string }, label_en: { type: string } } } }
                      intents: { type: array, items: { type: object, properties: { key: { type: string }, label_de: { type: string }, label_en: { type: string }, v1_projection: { type: string } } } }
                      tag_groups: { type: array, items: { type: object, properties: { key: { type: string }, label_de: { type: string }, label_en: { type: string }, sort: { type: integer } } } }
                      tags: { type: array, items: { type: object, properties: { key: { type: string }, tag_group_key: { type: string }, label_de: { type: string }, label_en: { type: string } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /api/v2/usage:
    get:
      operationId: getUsage
      summary: Usage snapshot for your organization (API calls, documents, storage)
      description: >-
        Org-scoped, read-only. The period is the current calendar month,
        month-to-date (UTC); period_start is echoed back. api_calls.by_route is
        sourced from request telemetry with 90-day retention. limits.* are null
        (unlimited) — placeholders for future per-org quotas.
      tags: [Usage]
      responses:
        "200":
          description: Current usage for the authenticated organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      period_start: { type: string, format: date-time }
                      api_calls:
                        type: object
                        properties:
                          this_period: { type: integer }
                          by_route:
                            type: array
                            items: { type: object, properties: { route: { type: string }, request_count: { type: integer }, last_seen: { type: string, format: date-time } } }
                      documents:
                        type: object
                        properties:
                          total_stored: { type: integer }
                          ingested_this_period: { type: integer }
                      storage:
                        type: object
                        properties:
                          bytes_used: { type: integer, format: int64 }
                      remaining:
                        type: object
                        properties:
                          rate_limit: { type: integer }
                          rate_limit_reset: { type: integer, description: "Unix timestamp (seconds)." }
                      limits:
                        type: object
                        properties:
                          documents_max: { type: [integer, "null"], description: "null = unlimited." }
                          api_calls_max: { type: [integer, "null"], description: "null = unlimited." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /api/v2/search:
    post:
      operationId: searchDocuments
      summary: Hybrid semantic + keyword search over your organization's documents
      description: >-
        Matches the query by meaning (semantic) and by exact string (keyword)
        and fuses the two ranked lists (reciprocal-rank fusion) into one.
        matched_via reports which leg(s) surfaced each hit. Read-only; the org
        is taken from the API key. Availability is per organization — returns
        403 until search is enabled for your org. A hybrid request stays
        available if the semantic component is briefly unavailable (it returns
        keyword-only results and meta.mode becomes "keyword"); an explicit
        mode:"semantic" request returns 503 in that case.
      tags: [Search]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Natural-language query." }
                mode: { type: string, enum: [hybrid, semantic, keyword], default: hybrid }
                limit: { type: integer, minimum: 1, maximum: 100, default: 25 }
                doc_type: { type: string, description: "Restrict to a document type (same vocabulary as GET /documents)." }
                bereich: { type: string, description: "Restrict to a Bereich." }
                intent: { type: string, description: "Restrict to an intent." }
                tags: { type: array, items: { type: string }, description: "Restrict to documents carrying ALL of these tag keys." }
                created_after: { type: string, format: date, description: "Only documents created on or after this date (YYYY-MM-DD)." }
                created_before: { type: string, format: date, description: "Only documents created before this date (YYYY-MM-DD)." }
                sender: { type: string, description: "Absender — substring match on the document's sender name." }
                receiver: { type: string, description: "Empfänger — substring match on the document's receiver name." }
                property: { type: string, description: "Liegenschaft — substring match on the document's property name or address." }
      responses:
        "200":
          description: Ranked, fused results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        document_id: { type: string, format: uuid }
                        filename: { type: [string, "null"] }
                        doc_type: { type: [string, "null"] }
                        score: { type: number, description: "Fused relevance score; higher is better. Use for ordering, not as an absolute threshold." }
                        snippet: { type: [string, "null"], description: "Passage centered on the first query-term match (≤ 280 chars), HTML-escaped with matched terms wrapped in <mark>…</mark>." }
                        chunk_index: { type: [integer, "null"] }
                        matched_via:
                          type: array
                          items: { type: string, enum: [semantic, keyword] }
                  meta:
                    type: object
                    properties:
                      query_id: { type: string, format: uuid }
                      mode: { type: string, enum: [hybrid, semantic, keyword], description: "The mode that actually ran." }
                      took_ms: { type: integer }
                      total: { type: integer }
        "400":
          description: Missing or invalid query (details in error).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Semantic search is not enabled for this organization.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Explicit mode:"semantic" request while the semantic component is temporarily unavailable. A hybrid request degrades to keyword instead.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /api/v2/search/answer:
    post:
      operationId: searchAnswer
      summary: RAG synthesized answer with cited sources (optional, search phase 3)
      description: >-
        Runs the same hybrid search as POST /api/v2/search, then asks a chat
        LLM to synthesize an answer over the top matching excerpts. Every
        factual claim in the answer is cited with a bracketed excerpt number;
        `sources` lists only the excerpts the model actually cited, so a
        citation the model invents never produces a fabricated source entry.
        Read-only; the org is taken from the API key. Availability is per
        organization — returns 403 until enabled, separately from plain
        search (an org can use POST /api/v2/search without opting into answer
        synthesis). No matching documents returns a fixed "no information"
        answer with zero LLM calls. The LLM provider is EU/CH-compliant
        (Switzerland North), matching the residency rules the rest of the AI
        pipeline follows.
      tags: [Search]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Natural-language question." }
                mode: { type: string, enum: [hybrid, semantic, keyword], default: hybrid }
                limit: { type: integer, minimum: 1, maximum: 10, default: 5, description: "Number of context chunks fed to the LLM." }
                doc_type: { type: string, description: "Restrict to a document type (same vocabulary as GET /documents)." }
                bereich: { type: string, description: "Restrict to a Bereich." }
                intent: { type: string, description: "Restrict to an intent." }
                tags: { type: array, items: { type: string }, description: "Restrict to documents carrying ALL of these tag keys." }
                created_after: { type: string, format: date, description: "Only documents created on or after this date (YYYY-MM-DD)." }
                created_before: { type: string, format: date, description: "Only documents created before this date (YYYY-MM-DD)." }
                sender: { type: string, description: "Absender — substring match on the document's sender name." }
                receiver: { type: string, description: "Empfänger — substring match on the document's receiver name." }
                property: { type: string, description: "Liegenschaft — substring match on the document's property name or address." }
      responses:
        "200":
          description: Synthesized answer with cited sources.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answer: { type: string, description: "The synthesized answer. Factual claims are cited inline as [1], [2], etc." }
                      sources:
                        type: array
                        description: "Only excerpts the model actually cited — never a fabricated reference."
                        items:
                          type: object
                          properties:
                            marker: { type: string, description: "The inline citation marker, e.g. \"[1]\"." }
                            document_id: { type: string, format: uuid }
                            chunk_index: { type: [integer, "null"] }
                            filename: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      query_id: { type: string, format: uuid }
                      mode: { type: string, enum: [hybrid, semantic, keyword], description: "The search mode that actually ran." }
                      took_ms: { type: integer }
                      chunks_used: { type: integer, description: "Number of context chunks fed to the LLM (0 = no matching documents, fixed answer, no LLM call)." }
        "400":
          description: Missing or invalid query (details in error).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: RAG answer synthesis is not enabled for this organization.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: The LLM provider is temporarily unavailable (or an explicit mode:"semantic" request while the semantic component is down).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

webhooks:
  document.processed:
    post:
      summary: Legacy event — document finished processing (deprecated nested `data` wrapper preserved).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: document.processed }
                timestamp: { type: string, format: date-time }
                classification_v3:
                  description: Present for v3-opt-in organizations.
                  oneOf: [{ $ref: "#/components/schemas/ClassificationV3" }, { type: "null" }]
                extracted_contacts:
                  description: >
                    v3-opt-in orgs only; omitted when the document has no
                    contacts. Deduplicated contact records for this document
                    with per-contact extraction confidence and an explicit
                    relationships pairing signal (contact_id references a
                    sibling entry in this same array).
                  type: array
                  items: { $ref: "#/components/schemas/ExtractedContact" }
                data: { type: object, additionalProperties: true }
      responses:
        "200": { description: "Acknowledge with any 2xx within the delivery timeout." }
  document.failed:
    post:
      summary: Pipeline exhausted retries for a document (opt-in; clean root format).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: document.failed }
                timestamp: { type: string, format: date-time }
                document_id: { type: string, format: uuid }
                filename: { type: string }
                external_id: { type: [string, "null"] }
                error_stage: { type: string, enum: [download, extract, ai, persist, unknown] }
                error_message: { type: string, description: "Sanitized — paths/URLs stripped, 500-char cap." }
      responses:
        "200": { description: "Acknowledge with any 2xx." }
  classification.changed:
    post:
      summary: A classification axis changed via the write API (opt-in; one event per touched axis).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: classification.changed }
                timestamp: { type: string, format: date-time }
                document_id: { type: string, format: uuid }
                field:
                  type: string
                  description: The classification axis (or tag set) that changed.
                  enum: [doc_type, doc_subtype, doc_bereich, intent_v3, status_lifecycle, zahlungsstatus, tags]
                old:
                  description: "Previous value: a string, null, or — for field=tags — an array of tag keys."
                new:
                  description: "New value: a string, null, or — for field=tags — an array of tag keys."
                actor:
                  type: object
                  description: Who made the change.
                  properties:
                    kind: { type: string, enum: [user, api_key] }
                    id: { type: [string, "null"], description: "User id or API-key id; null for system." }
      responses:
        "200": { description: "Acknowledge with any 2xx." }
  batch.completed:
    post:
      summary: Every document in an upload batch reached a terminal state (opt-in).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: batch.completed }
                timestamp: { type: string, format: date-time }
                batch_id: { type: string, format: uuid }
                total_files: { type: integer }
                processed_files: { type: integer, description: "Documents that finished (completed or failed)." }
                status: { type: string, enum: [completed, failed] }
                documents:
                  type: array
                  description: Per-document summary for the batch.
                  items:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      filename: { type: string }
                      status: { type: string, enum: [completed, failed] }
                      doc_type: { type: [string, "null"] }
      responses:
        "200": { description: "Acknowledge with any 2xx." }
  tag.suggested:
    post:
      summary: AI proposed a tag outside the controlled taxonomy (opt-in).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: tag.suggested }
                timestamp: { type: string, format: date-time }
                document_id: { type: string, format: uuid }
                suggested_tag: { type: string, description: "The proposed tag key not yet in the org's allowed set." }
                confidence: { type: number, format: float, description: "Model confidence on the suggestion (0..1)." }
      responses:
        "200": { description: "Acknowledge with any 2xx." }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key from Settings → API Keys (idms_…)."

  headers:
    XRateLimitLimit:
      description: Requests allowed per minute.
      schema: { type: integer }
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    XRateLimitReset:
      description: Unix timestamp (seconds) when the window resets.
      schema: { type: integer }

  responses:
    DocumentDetailResponse:
      description: Full updated document detail.
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { $ref: "#/components/schemas/DocumentDetail" }
              error: { type: "null" }
    TagsResponse:
      description: Updated tag set for the document.
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  document_id: { type: string, format: uuid }
                  tags: { type: array, items: { type: object, properties: { tag_key: { type: string }, confidence: { type: [number, "null"] } } } }
              meta:
                type: object
                properties:
                  added: { type: array, items: { type: string } }
                  removed: { type: array, items: { type: string } }
    BadRequest:
      description: "Invalid input — bad JSON, missing field, bad enum, DOCUMENT_LOCKED, out-of-org contact_id, Office lock file, bulk over 100 items."
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    Unauthorized:
      description: Missing or invalid API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    NotFound:
      description: Document not in the caller's organization or no such id (never 403).
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    PayloadTooLarge:
      description: Over 4 MB (multipart /upload) or over 250 MB (presigned).
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    UnknownTaxonomyKey:
      description: Unknown doc_type / doc_subtype / intent / tag key.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    RateLimited:
      description: Over 100 requests/minute — wait until X-RateLimit-Reset.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    ServerError:
      description: Unexpected internal failure — retry; contact support if persistent.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  schemas:
    ErrorEnvelope:
      type: object
      properties:
        data: { type: "null" }
        meta: { type: "null" }
        error: { type: string }
        code: { type: string, description: "Machine-readable code on selected errors (e.g. DOCUMENT_LOCKED)." }

    PageMeta:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        total_pages: { type: integer }
        server_time: { type: string, format: date-time, description: "Server query time — the delta-sync cursor; save it and pass as the next updated_since." }

    IdempotencyMeta:
      type: object
      properties:
        idempotent_replay: { type: boolean }
        dedup_by: { type: string, enum: [external_id, content_hash] }
        forced: { type: boolean, description: "Present (true) when force=true created this document bypassing idempotency." }

    EntityRef:
      type: object
      description: Lightweight extraction snapshot embedded on the document row.
      properties:
        name: { type: string }
        address: { type: string }
        reference_id: { type: string }

    RelatedContact:
      type: object
      properties:
        name: { type: string }
        role: { type: string }
        reference_id: { type: string }

    Signature:
      type: object
      properties:
        signed: { type: boolean }
        signed_by: { type: [string, "null"] }
        signed_at: { type: [string, "null"] }
        confidence: { type: [number, "null"] }
        raw: { type: [object, "null"], additionalProperties: true }

    ClassificationV3:
      type: object
      description: Current classification model record (nested on document rows).
      properties:
        doc_type: { type: string }
        doc_bereich: { type: string }
        doc_subtype: { type: [string, "null"] }
        intent_v3: { type: string }
        status_lifecycle: { type: string, enum: [neu, in_bearbeitung, abgeschlossen, sonstiges, finalized, deleted] }
        zahlungsstatus: { type: [string, "null"], enum: [offen, bezahlt, teilbezahlt, ueberfaellig, sonstiges, null] }

    Document:
      type: object
      description: List-row shape — legacy envelope plus nested current-model classification and tags.
      properties:
        id: { type: string, format: uuid }
        filename: { type: string }
        file_type: { type: string }
        file_size: { type: integer }
        status: { type: string, description: "legacy pipeline status (pending / processing / completed / failed)." }
        classification: { type: [string, "null"] }
        classification_confidence: { type: [number, "null"] }
        doc_type: { type: [string, "null"], description: "legacy type (see v1_projection in the taxonomy)." }
        doc_subtype: { type: [string, "null"] }
        doc_intent: { type: [string, "null"] }
        sender: { oneOf: [{ $ref: "#/components/schemas/EntityRef" }, { type: "null" }] }
        receiver: { oneOf: [{ $ref: "#/components/schemas/EntityRef" }, { type: "null" }] }
        related_contacts: { type: [array, "null"], items: { $ref: "#/components/schemas/RelatedContact" } }
        property: { oneOf: [{ $ref: "#/components/schemas/EntityRef" }, { type: "null" }] }
        unit: { type: [object, "null"], properties: { name: { type: string }, type: { type: string }, property_reference: { type: string } } }
        equipment: { type: [object, "null"], properties: { name: { type: string }, type: { type: string }, reference_id: { type: string } } }
        signature: { oneOf: [{ $ref: "#/components/schemas/Signature" }, { type: "null" }] }
        language: { type: [string, "null"] }
        extracted_metadata: { type: [object, "null"], additionalProperties: true }
        external_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        document_classification_v3:
          oneOf:
            - { $ref: "#/components/schemas/ClassificationV3" }
            - { type: "null" }
        document_tags: { type: array, items: { type: object, properties: { tag_key: { type: string }, confidence: { type: [number, "null"] } } } }
        document_tags_v3: { type: array, items: { type: object, properties: { tag_key: { type: string }, confidence: { type: [number, "null"] } } } }

    DocumentDetail:
      allOf:
        - { $ref: "#/components/schemas/Document" }
        - type: object
          properties:
            contacts: { type: array, items: { $ref: "#/components/schemas/Contact" } }
            extracted_contacts:
              type: ["array", "null"]
              description: "Opted-in organizations only; omitted when the document has no contacts. Same array the document.processed webhook delivers."
              items: { $ref: "#/components/schemas/ExtractedContact" }

    Contact:
      type: object
      description: >
        Org-level deduplicated contact. Ids may disappear when duplicates are
        merged — treat document_id as the stable key.
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [person, firma] }
        role: { type: string, enum: [absender, empfaenger, cc], description: "Role on THIS document." }
        name: { type: string }
        first_name: { type: [string, "null"], description: "Person only." }
        last_name: { type: [string, "null"], description: "Person only." }
        salutation: { type: [string, "null"], description: "Person only." }
        function: { type: [string, "null"], description: "Person only — role/job title as stated in documents (e.g. Geschäftsführerin)." }
        date_of_birth: { type: [string, "null"], format: date, description: "Person only — ISO YYYY-MM-DD." }
        email: { type: [string, "null"] }
        emails: { type: array, items: { type: string } }
        phone: { type: [string, "null"] }
        phones: { type: array, items: { type: string } }
        address: { type: [string, "null"] }
        website: { type: [string, "null"], description: "Firma only." }
        vat_number: { type: [string, "null"], description: "Firma only — UID/VAT." }
        hr_number: { type: [string, "null"], description: "Firma only — commercial-register number." }
        external_id: { type: [string, "null"] }

    ExtractedContact:
      description: >
        A contact as delivered in `extracted_contacts` (the `document.processed`
        webhook and `GET /api/v2/documents/:id`): the base Contact plus per-document
        extraction confidence and relationship pairing signals. v3-opt-in orgs only.
      allOf:
        - { $ref: "#/components/schemas/Contact" }
        - type: object
          properties:
            confidence:
              type: [number, "null"]
              minimum: 0
              maximum: 1
              description: "Per-document extraction confidence (0..1)."
            relationships:
              type: array
              description: >
                Directed links to sibling entries in this same array. An empty
                array is an explicit "no relationships", not "unknown".
              items:
                type: object
                properties:
                  contact_id: { type: string, format: uuid, description: "References another contact's id in this same array." }
                  relationship_type: { type: string, enum: [employee_of, represents, related_to] }

    UploadedDocument:
      type: object
      properties:
        id: { type: string, format: uuid }
        filename: { type: string }
        status: { type: string }
        file_type: { type: [string, "null"] }
        file_size: { type: [integer, "null"] }
        external_id: { type: [string, "null"] }
        content_hash: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    ZipItem:
      type: object
      properties:
        kind: { type: string, enum: [accepted, idempotent_replay, rejected] }
        id: { type: [string, "null"], format: uuid }
        filename: { type: string }
        file_type: { type: [string, "null"] }
        file_size: { type: [integer, "null"] }
        external_id: { type: [string, "null"] }
        content_hash: { type: [string, "null"] }
        status: { type: [string, "null"] }
        rejected_reason:
          type: [string, "null"]
          enum: [directory_entry, nested_zip_not_supported, entry_too_large, unsupported_type, empty_entry, office_lock_file, null]

    ClassificationPatch:
      type: object
      description: All fields optional; set a field to null to clear it.
      properties:
        doc_type: { type: [string, "null"] }
        doc_subtype: { type: [string, "null"] }
        doc_bereich: { type: [string, "null"], description: "Auto-derived from doc_type when omitted." }
        intent: { type: [string, "null"] }
        status_lifecycle: { type: [string, "null"], enum: [neu, in_bearbeitung, abgeschlossen, sonstiges, finalized, deleted, null] }
        zahlungsstatus: { type: [string, "null"], enum: [offen, bezahlt, teilbezahlt, ueberfaellig, sonstiges, null] }
        tags: { type: array, items: { type: string }, description: "Replaces the full tag set." }
