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* — an out-of-org document
      is a `404`, indistinguishable from a missing resource. `403` is reserved for
      capability: a feature not enabled for your organization (`POST /api/v2/search`,
      `POST /api/v2/search/answer`) and a `storage_path` outside your org's folder on
      `POST /api/v2/documents/upload/finalize`.
  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. Empty or unparseable value → no filter, silently (no 400). 0 is a real filter at zero and excludes every document with no extractable total.", schema: { type: number } }
        - { name: betrag_max, in: query, description: "Maximum extracted total amount. Empty or unparseable value → no filter, silently (no 400). 0 is a real filter at zero and excludes every document with no extractable total.", schema: { type: number } }
        - { name: faellig_after, in: query, description: "Due date on/after this date (YYYY-MM-DD). Empty or non-YYYY-MM-DD value → no filter, silently (no 400).", schema: { type: string, format: date } }
        - { name: faellig_before, in: query, description: "Due date on/before this date (YYYY-MM-DD). Empty or non-YYYY-MM-DD value → no filter, silently (no 400).", 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 }, description: "Pflichtfelder EXTRACTION could have supplied and did not — a value we failed to read, or one the document does not state. Actionable. Until 2026-08-26 this list also carried fields extraction cannot fill at all, which made it the same 21 names on every document; those moved to missing_client_fields." }
                      missing_client_fields: { type: array, items: { type: string }, description: "Pflichtfelder extraction CANNOT supply, by definition — they come from your own system or from the routing decision (Organisation, Belegart, Project Name, Account No., cost-centre and OU fields). Present so the required list above means what its name says. Added 2026-08-26; additive, and no existing field changed shape." }
                  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)." }
                mandate_id:
                  type: string
                  format: uuid
                  description: >-
                    Optional. A plain form field, like external_id. The mandant
                    must resolve inside the caller's organisation or the upload
                    is refused with 422 — one that does not resolve would be a
                    silent misfiling. An EMPTY value (or whitespace only) is
                    treated as absent, not as an error. Takes precedence over
                    mory_mandant when both are sent.
                mory_mandant:
                  type: string
                  description: >-
                    Optional alternative to mandate_id, as a JSON STRING in the
                    form field (this route is multipart, so it cannot carry a
                    nested object): {"type":"company","id":"<uuid>"}. type is
                    company or person; anything else is 422. Ignored when
                    mandate_id is also sent.
      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. Optional: when omitted (and force is not set) the server computes it from the uploaded object, so content dedup applies either way." }
                force: { type: boolean, description: "Testing aid — bypass content-hash idempotency (row stored without a content hash)." }
                metadata: { type: object, additionalProperties: true }
                mandate_id:
                  type: string
                  format: uuid
                  description: >-
                    Optional. The presigned path names the mandant HERE, not on
                    /upload/init, which only signs a URL and has no row to carry
                    one. Must resolve inside the caller's organisation or the
                    call is refused with 422. An empty value is treated as
                    absent. Takes precedence over mory_mandant.
                mory_mandant:
                  $ref: "#/components/schemas/MoryMandant"
      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." }
                mandate_id:
                  type: string
                  format: uuid
                  description: >-
                    Optional. A plain form field, like external_id. The mandant
                    must resolve inside the caller's organisation or the upload
                    is refused with 422 — one that does not resolve would be a
                    silent misfiling. An EMPTY value (or whitespace only) is
                    treated as absent, not as an error. Takes precedence over
                    mory_mandant when both are sent. ONE mandant for the whole
                    batch: it is resolved BEFORE the loop and a bad one refuses
                    the ENTIRE request, because a batch half filed under a
                    mandant and half not is worse than one that is refused.
                mory_mandant:
                  type: string
                  description: >-
                    Optional alternative to mandate_id, as a JSON STRING in the
                    form field (this route is multipart, so it cannot carry a
                    nested object): {"type":"company","id":"<uuid>"}. type is
                    company or person; anything else is 422. Ignored when
                    mandate_id is also sent.
      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/embed-token:
    post:
      operationId: postEmbedToken
      summary: Mint a short-lived token for the embedded chat iframe
      description: >-
        Call this from YOUR SERVER with your iDMS API key, then put the returned
        token in the iframe URL. Never put the API key itself in a URL or in a
        browser: it is organization-scoped, grants the whole v2 API over your
        entire corpus, and would end up in browser history, in the Referer
        header and in your own access logs. The token returned here is valid for
        15 minutes, is scoped to the embedded chat only, and cannot be used
        against any other endpoint.

        The token can only be framed by an origin your organization has listed
        under Settings -> Chat & Embed. A request from an organization with no
        listed origin is refused here rather than producing a token that no page
        could use.
      tags: [Embed]
      responses:
        "200":
          description: Token minted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      token: { type: string, description: "Signed, opaque. Put it in the iframe URL as ?token=" }
                      expires_in: { type: integer, description: "Seconds until the token expires (900)." }
                      embed_path: { type: string, description: "Ready-made path to frame, token already appended." }
                      scope:
                        type: object
                        description: "What the token grants. An object from day one so a per-subject scope can be added without breaking callers."
                        properties:
                          org_id: { type: string, format: uuid }
                      warnings:
                        type: array
                        items: { type: string }
                        description: >-
                          Non-blocking problems worth fixing before you embed this
                          page. The token is still minted. "chat_answers_disabled"
                          means chat answers are switched off for this
                          organization (Settings -> Chat & Embed), so the iframe
                          will load but every question will get a 403 until
                          someone turns that switch on. Empty when there is
                          nothing to warn about.
        "403":
          description: >-
            Embedded chat is not enabled for your organization, or your
            organization has no allowed origins configured. The error message
            says which.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "503":
          description: This deployment has no embed signing secret configured.
          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 defaults to the current calendar
        month, month-to-date (UTC); pass ?period=YYYY-MM for a past month.
        period_start is echoed back, and data.source reports the provenance
        of api_calls.by_route: "live" (current month, request telemetry with 90-day
        retention) or "rollup" (past month, durable monthly aggregate that
        outlives that retention). limits.* are the quotas configured for the
        organization; null means unlimited (the default). They are reported
        whether or not enforcement is switched on for the org — when it is,
        exceeding one returns 429.
      tags: [Usage]
      parameters:
        - name: period
          in: query
          description: >-
            Calendar month to report, YYYY-MM (UTC). Omitted = current month,
            month-to-date. A malformed value returns 400 (it is not silently
            ignored).
          schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
      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 }
                      source:
                        type: string
                        enum: [live, rollup]
                        description: >-
                          Provenance of api_calls.by_route: "live" = current
                          month from raw telemetry, "rollup" = a past month from
                          the durable monthly aggregate. documents.total_stored
                          and storage.bytes_used are always point-in-time and are
                          never reconstructed for a historical period.
                      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 }
                      search:
                        type: object
                        properties:
                          this_period:
                            type: integer
                            description: >-
                              POST /api/v2/search calls in the requested period,
                              lifted out of api_calls.by_route so it can be
                              compared directly against limits.search_queries_max.
                      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
                        description: >-
                          Quotas configured for the organization. Reported
                          whether or not enforcement is switched on for it.
                        properties:
                          documents_max: { type: [integer, "null"], description: "null = unlimited (the default)." }
                          api_calls_max: { type: [integer, "null"], description: "null = unlimited (the default)." }
                          search_queries_max: { type: [integer, "null"], description: "null = unlimited (the default). Counted against search.this_period." }
        "400":
          description: Malformed period — expected YYYY-MM.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "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." }
                betrag_min: { type: number, description: "Minimum extracted total amount. null, empty string or unparseable value → no filter, silently (no 400). 0 is a real filter at zero and excludes every document with no extractable total." }
                betrag_max: { type: number, description: "Maximum extracted total amount. null, empty string or unparseable value → no filter, silently (no 400). 0 is a real filter at zero and excludes every document with no extractable total." }
                faellig_after: { type: string, format: date, description: "Due date on/after this date (YYYY-MM-DD). null, empty string or non-YYYY-MM-DD value → no filter, silently (no 400)." }
                faellig_before: { type: string, format: date, description: "Due date on/before this date (YYYY-MM-DD). null, empty string or non-YYYY-MM-DD value → no filter, silently (no 400)." }
      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, description: "Number of results in `data`." }
                      keyword_match_count: { type: [integer, "null"], description: "Documents matching the query lexically across the whole corpus, before `limit`. Null when the keyword component did not run. In hybrid mode may be smaller than `total` — the semantic component contributes documents it never counted, and has no match threshold to count by." }
        "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 over your documents — it never modifies them; 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.


        Stateless unless you pass `thread_id`. Without it the call reads and
        writes nothing — one question, one answer, nothing remembered. With it,
        the thread's recent turns are replayed to the model as context and this
        exchange is appended to the thread. A `thread_id` belonging to another
        organization returns 404, never 403.
      tags: [Search]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Natural-language question." }
                thread_id: { type: string, format: uuid, description: "Optional conversation handle. Omit it and the call is stateless: nothing is read, nothing is stored. Supply it and the thread's recent turns are replayed to the model as context and this exchange is appended. A thread from another organization returns 404." }
                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)." }
                      thread_id: { type: [string, "null"], format: uuid, description: "Echo of the thread this exchange belongs to, or null for a stateless call." }
                      thread_persisted: { type: [boolean, "null"], description: "Whether the exchange was stored. Null for a stateless call. False means the answer is valid but appending it to the thread failed — the answer is never withheld over a bookkeeping error, so this reports it instead of hiding it." }
        "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" } } }
        "404":
          description: >-
            No such thread. Returned for a `thread_id` that does not exist, is
            malformed, or belongs to another organization — the three are
            deliberately indistinguishable, so a 404 never confirms that a
            thread you cannot reach exists.
          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" } } }

  /api/v2/mandates:
    post:
      tags: [Mandates]
      summary: Create a mandant, idempotent on the mory pair
      description: >-
        A mandant is an attribute of a document, not a storage partition: one
        organisation holds all of a client's mandants and nothing here moves a
        document anywhere. Identity is the mory pair {type, id}, and a repeat
        create with the same pair returns 200 with meta.idempotent_replay true,
        never a second row and never a 409.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MandateCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Mandate" }
                  meta: { type: object, properties: { idempotent_replay: { type: boolean, const: false } } }
                  error: { type: "null" }
        "200":
          description: >-
            A replay. The same mory pair was already created in this
            organisation, so the existing record is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Mandate" }
                  meta: { type: object, properties: { idempotent_replay: { type: boolean, const: true } } }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: parent_id is unknown or belongs to another organisation.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "409":
          description: >-
            code is already used by a live mandant in this organisation. The
            existing id is in error.details.existing_mandate_id.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "422":
          description: Validation failed.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Mandates]
      summary: List mandants
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: updated_since, in: query, schema: { type: string, format: date-time }, description: "Delta query: everything changed at or after this instant." }
        - { name: mory_mandant_type, in: query, schema: { type: string, enum: [company, person] }, description: "Used together with mory_mandant_id." }
        - { name: mory_mandant_id, in: query, schema: { type: string, format: uuid } }
        - { name: code, in: query, schema: { type: string } }
        - { name: include_ended, in: query, schema: { type: boolean, default: false }, description: "Retired mandants are out of the list unless asked for. Their documents are untouched either way." }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 1000 } }
        - { name: cursor, in: query, schema: { type: string }, description: "Opaque. Pass meta.next_cursor from the previous page." }
      responses:
        "200":
          description: A page of mandants.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Mandate" } }
                  meta:
                    type: object
                    properties:
                      next_cursor:
                        type: [string, "null"]
                        description: "Null on the last page, so a client can stop without a second request."
                      limit: { type: integer }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: A filter value could not be read.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/mandates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Mandates]
      summary: One mandant
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: The mandant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Mandate" }
                  meta: { type: "null" }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Mandates]
      summary: Update a mandant
      description: >-
        Partial update. Setting ended_at retires a mandant and does NOT move,
        reassign or otherwise touch a single document: a document from 2023
        belonged to that mandant in 2023 and stays there. mory_mandant is not
        patchable, because the pair is the row's identity.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MandatePatch" }
      responses:
        "200":
          description: The updated mandant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Mandate" }
                  meta: { type: "null" }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: code is already used by another live mandant in this organisation.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "422":
          description: Validation failed, or a field that is not patchable was sent.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/documents/{id}/mandate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      tags: [Mandates]
      summary: A person resolves a document's mandant
      description: >-
        Writes exactly one mandate_history row and sets mandate_source to
        manual. The verdict is RECORDED, not acted on: nothing learns from it in
        this pass. It is asked for rather than inferred because the three cases
        are different repairs, and collapsing them is the quietest way to teach
        the system the wrong thing later.

        The verdict is REQUIRED only when the document's current mandate_source
        is routed, because only then did a machine choose and only then is there
        a mistake to learn from. Omitting it on a routed document returns 422.
        For a document whose source is provided or manual, or that has none, the
        verdict may be omitted and is stored as null.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mandate_id]
              properties:
                mandate_id: { type: string, format: uuid }
                verdict:
                  type: string
                  enum: [new_property, filing_wrong, extraction_wrong]
                  description: >-
                    Required when the document's current mandate_source is
                    routed; optional otherwise, and stored as null when omitted.
                    An explicit null is refused: omitting it says you did not
                    answer, and null would claim there is no answer.
                    new_property: the address is real, the register did not have
                    it. filing_wrong: the register is right, the filing was not.
                    extraction_wrong: iDMS misread the document.
                expected_updated_at:
                  type: string
                  format: date-time
                  description: "Optional. On mismatch the call returns 409 and writes nothing, which is how two people resolving the same document at once is caught."
      responses:
        "200":
          description: The document's mandant after the change.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      mandate_id: { type: string, format: uuid }
                      mandate_source: { type: string, const: manual }
                      mandate_decided_at: { type: string, format: date-time }
                      updated_at: { type: string, format: date-time }
                      verdict: { type: [string, "null"], description: "Echoes what was sent, or null when it was omitted." }
                  meta: { type: "null" }
                  error: { type: "null" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: expected_updated_at did not match. Nothing was written.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "422":
          description: Validation failed.
          content: { application/json: { schema: { $ref: "#/components/schemas/MandateErrorEnvelope" } } }
        "429": { $ref: "#/components/responses/RateLimited" }

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" }
                mandate:
                  description: >
                    MOR-1479. Present ONLY for an organisation whose
                    org_config.mandate_block_enabled is true, which is false by
                    default. With the flag off the key is absent entirely and
                    the payload is byte identical to the one every existing
                    subscriber receives today. No new event type was added:
                    conflict and uncertainty ride on this event by design.
                  $ref: "#/components/schemas/MandateBlock"
                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." }
  usage.threshold.reached:
    post:
      summary: >-
        Org's API-call count for the calendar month first crossed its configured
        alert threshold (opt-in; clean root format).
      description: >-
        Fires at most once per calendar month per organization — the first
        crossing sends one event and later calls that month do not re-fire. The
        threshold is configured in Settings; it is an alert, not the quota that
        produces a 429 (see limits.* on GET /api/v2/usage).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: usage.threshold.reached }
                timestamp: { type: string, format: date-time }
                organization_id: { type: string, format: uuid }
                period_start: { type: string, format: date-time, description: "First-of-month UTC instant the count is measured over." }
                threshold: { type: integer, description: "The configured alert threshold." }
                api_calls: { type: integer, description: "Month-to-date count at the moment of crossing; always >= threshold." }
      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. A cross-org
        lookup is this 404, never a 403 — existence is never disclosed.
      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: >-
        Two distinct causes. Per-key rate limit — over 100 requests/minute, wait
        until X-RateLimit-Reset. Or a configured monthly usage quota for the
        organization is reached (api_calls_max, documents_max,
        search_queries_max); the error message names the quota and the counts,
        and X-RateLimit-Reset does NOT apply because that window is the calendar
        month.
      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:
    MoryMandant:
      type: object
      description: "The identity pair as mory's document-mandants feed returns it, stored verbatim."
      required: [type, id]
      properties:
        type: { type: string, enum: [company, person] }
        id: { type: string, format: uuid }

    Mandate:
      type: object
      properties:
        id: { type: string, format: uuid }
        mory_mandant:
          oneOf:
            - { $ref: "#/components/schemas/MoryMandant" }
            - { type: "null" }
        name: { type: string, description: "What a person sees in triage." }
        type: { type: string, enum: [mandate, mandate_group] }
        parent_id: { type: [string, "null"], format: uuid }
        code: { type: [string, "null"], description: "Short code such as M36. Unique per organisation while the mandant is live." }
        uid: { type: [string, "null"], description: "CHE number." }
        seat_address: { type: [object, "null"], description: "The entity's own address, NOT a managed property." }
        routing_enabled: { type: boolean, default: false, description: "Per mandant, off by default. Nothing routes in this pass regardless." }
        ended_at: { type: [string, "null"], format: date }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    MandateCreate:
      type: object
      required: [mory_mandant, name]
      properties:
        mory_mandant: { $ref: "#/components/schemas/MoryMandant" }
        name: { type: string }
        type: { type: string, enum: [mandate, mandate_group], default: mandate }
        parent_id: { type: string, format: uuid }
        code: { type: string }
        uid: { type: string }
        seat_address: { type: object }
        routing_enabled: { type: boolean, default: false }

    MandatePatch:
      type: object
      description: "Partial. mory_mandant is deliberately absent: the pair is the row's identity."
      properties:
        name: { type: string }
        code: { type: [string, "null"] }
        uid: { type: [string, "null"] }
        parent_id: { type: [string, "null"], format: uuid }
        seat_address: { type: [object, "null"] }
        routing_enabled: { type: boolean }
        ended_at: { type: [string, "null"], format: date }

    MandateErrorEnvelope:
      type: object
      description: >-
        The mandant routes answer with an OBJECT in `error`, not the string the
        other v2 routes use, because the contract requires the conflicting id in
        error.details. The envelope keys are unchanged.
      properties:
        data: { type: "null" }
        meta: { type: "null" }
        error:
          type: object
          properties:
            message: { type: string }
            details: { type: object }

    MandateBlock:
      type: object
      description: >-
        Carried on document.processed for an organisation whose
        org_config.mandate_block_enabled is true. DEFAULT FALSE: with the flag
        off the payload is byte identical to the one existing subscribers
        receive today, and the key is absent entirely.
      properties:
        id: { type: [string, "null"], format: uuid }
        mory_mandant:
          oneOf:
            - { $ref: "#/components/schemas/MoryMandant" }
            - { type: "null" }
        source:
          type: string
          enum: [provided, routed, manual, unresolved]
          description: "provided: mory sent it. routed: iDMS decided. manual: a person resolved it. unresolved: no mandant yet."
        state:
          type: string
          enum: [uncertain, conflict]
          description: >-
            Present ONLY when source is unresolved. uncertain: no signal spoke.
            conflict: two spoke and disagreed. Different jobs for a person, and
            not to be collapsed.
        confidence: { type: [number, "null"] }
        decided_at: { type: [string, "null"], format: date-time }
        signals:
          type: array
          items:
            type: object
            properties:
              name: { type: string, enum: [property_register, addressee, mandate_code, sender] }
              spoke: { type: boolean }
              evidence: { type: string }
              mandate_id: { type: string, format: uuid }
              reason:
                type: string
                enum: [not_present, address_has_no_street_number, address_not_in_register, no_mandate_entity_named, entity_seat_excluded, register_ambiguous, low_text_content]
                description: "An enum, never prose, so mory can translate it. Additive: values may be added, never changed."
        candidates: { type: array, items: { type: object } }

    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." }
        profile:
          type: object
          description: >-
            Present ONLY when an output profile was applied (?profile=, or an
            org default). Absent on an unmapped response.
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        missing_required_fields_per_id:
          type: object
          description: >-
            Present ONLY on a profile-mapped response. Maps document id → the
            Pflichtfelder that extraction could have supplied and did not.
            Documents with nothing missing are omitted, so an empty object means
            the whole page is complete. Advisory — the response is still 200.
          additionalProperties: { type: array, items: { type: string } }

    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]
          deprecated: true
          description: >-
            DEPRECATED (MOR-1426). A model guess, asked only for doc_type=rechnung and
            clamped to `sonstiges` on anything unexpected — nothing on an invoice says
            whether it was paid. Measured over 33'161 production documents on 2026-09-18:
            7'483 invoices carry `offen`, and 4'637 of those were already more than 90 days
            past due on the day they were uploaded. It is superseded by the derived
            `payment_status` (open | assumed_paid_by_age | paid | cancelled |
            not_applicable) with `payment_status_source` beside it. This field is still
            written and still filterable; it will not be removed without notice, and where
            the two disagree the derived one is the one computed from printed dates.

    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)." }
        extraction_state: { type: [string, "null"], enum: [extracted, unsupported_format, ocr_failed, ocr_unavailable, null], description: "Whether the file's text was read. null on documents processed before this field existed — NOT a claim that extraction succeeded. 'unsupported_format' means no extractor exists for the file type, so doc_type carries no information about the document's content." }
        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:
          $ref: "#/components/schemas/ExtractedMetadata"
        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"] } } } }

    ExtractedMetadata:
      type: [object, "null"]
      # OPEN ON PURPOSE. The extractor may add a key at any time and clients must
      # not break when it does — so `additionalProperties` stays true and the
      # named list below is a guarantee about what is DOCUMENTED, never a
      # guarantee about what is absent.
      additionalProperties: true
      description: >
        Fields the extractor read out of the document. Until 2026-08-26 this was
        declared as an opaque object with nothing named inside it, so a client
        integrating against this spec could not learn that `total_amount`
        exists — never mind `vat_amount`. Every field below has been in the
        RESPONSE since the day it was first written; what changed is that the
        spec now names them.


        A key may be absent entirely, present and null, or present and empty.
        Those three are not the same and the counts below say why: `vat_amount`
        is on 15 of 33'045 documents not because it fails but because the
        historical backfill was deliberately not run (MOR-1001). Treat an absent
        key as "not read", never as "zero".


        Keys beginning with `_` are internal markers and are stripped before
        serialisation. They will never appear in a response.


        Counts measured 2026-08-26 across all 33'045 production documents.
      properties:
        summary: { type: [string, "null"], description: "Short machine summary. 32'873 documents." }
        description: { type: [string, "null"], description: "Longer machine description. 32'865 documents." }
        suggested_tags: { type: [array, "null"], items: { type: object }, description: "Proposed tags awaiting confirmation. The key is on 31'842 documents and the array is EMPTY on ALL of them — zero non-empty, ever (measured 2026-08-26). Treat it as carrying nothing until MOR-1010 explains why." }
        total_amount: { type: [string, number, "null"], description: "Document total, bare number without a currency symbol. Key on 31'829 documents: a STRING on 15'762, JSON null on 16'065, and a NUMBER on exactly 2. Parse defensively — the two numbers are real and are not a rounding of the rule." }
        amount_direction: { type: [string, "null"], enum: [credit, null], description: "MOR-1438 (2026-09-16). Whose favour total_amount is in, when the page SAYS so. credit means the document's own total is a printed credit — c54c909a (an invoice, doc_type untouched per MOR-1429) ends Total zu Ihren Gunsten -1'167.70 — and total_amount already carries the sign; this names why. Present only when pageStatesCredit (page-states-credit.ts) — the page's own credit label, not merely a negative total corroborated by lineItemTotals or subtotal+VAT — is what licensed the sign in sanitizeTotalAmount; see amount_direction_source. Absent everywhere else, which is every positive total and every negative one licensed some other way. NOT MEASURED against production as of this write." }
        amount_direction_source: { type: [string, "null"], enum: [printed_credit_label, null], description: "MOR-1438. How amount_direction was determined; printed_credit_label is the only value today, present exactly where amount_direction is credit. A value and its provenance travel together (MOR-1001)." }
        subtotal_amount: { type: [string, "null"], description: "The subtotal the document itself PRINTS before VAT — the figure beside \"Zwischentotal\", \"Nettobetrag\" or \"Total exkl. MwSt\". Bare number, no currency symbol. Never derived from the total minus VAT and never summed from the positions: it exists so the positions can be checked against a figure the document stated. NEW — written from 2026-08-27 onward and absent on documents processed before that, so treat a missing key as \"not read yet\" rather than \"no subtotal printed\"; a stored null means the document prints none." }
        currency: { type: [string, "null"], description: "ISO code for total_amount. Key on 31'829 documents; a value on 24'697 and JSON null on 7'132." }
        due_date: { type: [string, "null"], description: "ISO date. 31'828 documents. MOR-1402: where the model read no due date and the page states a payment term in days (30 Tage netto) and an issue date, the two are assembled for an invoice or a reminder — see due_date_source. A printed due date is never replaced." }
        due_date_source: { type: [string, "null"], enum: [derived_from_terms, printed_label, qr_payload, null], description: "MOR-1436: qr_payload when the model read no due date, the page prints no labelled one, and the QR strip's /40/ states a net term (0:30) applied to the issue date. MOR-1414: printed_label when the model read no due date and the page prints ONE under a due label (Netto fällig / Fällig am / Zahlbar bis / Due date …) — a printed date beats a derivation. MOR-1402. derived_from_terms when due_date was assembled from payment_terms + date_issued (issue date + N calendar days, the NET term where a Skonto term names two); null when the page printed the due date or none could be derived. Only on invoice and reminder — measured 2026-09-14: 1'323 invoices and 12 reminders carried a term and no due date; 1'525 statements, 176 contracts and 59 quotes are deliberately not derived. A derived due date is read by the overdue chip and filter exactly as a printed one." }
        skonto: { type: [object, "null"], description: "MOR-1411 (2026-09-15). An EARLY-PAYMENT DISCOUNT THE PAGE OFFERS, as information — never a position, never a deduction. The invoice amount (total_amount) stays the invoice amount; the Skonto is what the client MAY pay less if the money arrives by the date, and iDMS has no payment data to know whether it did. Read from the sentence on the page (\"Bei Zahlung bis 19.09.2026: 2 % Skonto CHF 28.65\"): pct is the rate; amount is the Skonto amount as PRINTED and corroborated against total_amount (±0.05), null when the page prints only the rate (the reader never computes one); until is the ISO date the offer names, days the term in days where the page states one instead; amount_with_skonto the printed \"Zahlbetrag mit Skonto\", corroborated, else null; corroborated is true when a printed figure closed against the gross. Null when the page prints no Skonto, when it says the Skonto is already deducted, when two different rates are printed, or when a printed figure does not close. Every deduction reader refuses a figure equal to this Skonto, so it cannot become a prepayment row or an adjustment. Measured 2026-09-15: 120 invoices print a percentage Skonto; on 16 the amount is printed and corroborates, on 71 the rate alone. Book it on payment, not on receipt.", properties: { pct: { type: number }, amount: { type: [number, "null"] }, until: { type: [string, "null"], format: date }, days: { type: [integer, "null"] }, amount_with_skonto: { type: [number, "null"] }, corroborated: { type: boolean } } }
        payment_terms: { type: [string, "null"], description: "Free text as printed. 31'827 documents." }
        iban: { type: [string, "null"], description: "31'827 documents." }
        validity_duration: { type: [string, "null"], description: "Free text, e.g. \"12 Monate\". 31'827 documents." }
        qr_verified: { type: [boolean, "null"], description: "TRUE means the Swiss QR code was parsed, so the payment data on this document is machine-read rather than inferred. The KEY is on 31'827 documents and is always a boolean; it is TRUE on 3'911. Present-and-false is the common case and is not the same as absent." }
        reference_number: { type: [string, "null"], description: "MOR-1108, changed 2026-09-04 — THE PAYMENT REFERENCE AND NOTHING ELSE: the QR-Referenz / ESR reference a BANK matches a payment on (27 digits, MOD10 check digit), or an ISO 11649 creditor reference (RF...). Null where the document prints none, which is a correct and common answer. UNTIL THAT DATE it also carried whatever the extractor answered when there was no QR reference, which measured as the INVOICE NUMBER on two of three sampled documents — '5195152' (a Komm.-Nr.) and 'GC-26-2077' (a Rechnung Nr.). Those values now go to invoice_number. A client reading this field on a document processed before 2026-09-04 may still find an invoice number in it; no backfill was run. Key on 31'827 documents. See also qr_reference, the separate typed field." }
        reference_number_type: { type: [string, "null"], enum: [QRR, SCOR, null], description: "MOR-1108. Which standard reference_number follows. QRR is the 27-digit Swiss QR reference, checksum-bearing and tied to a QR-IBAN; SCOR is the ISO 11649 creditor reference used with an ordinary IBAN. They are not interchangeable — a bank rejects a QRR sent against a plain IBAN and vice versa — so an ERP booking from this needs to know which it was handed. Null where reference_number is null." }
        reference_number_source: { type: [string, "null"], enum: [qr, page, model, null], description: "MOR-1108. How reference_number was obtained, on MOR-1001's principle that a value and its provenance travel together. 'qr' = machine-read off the QR bill, the strongest read. 'page' = recovered from the document's own text and verified by the reference's own check digit — this is the tier that answers on layouts where the payment part's labels and values are separated, which is why parseQrBill returns nothing for them. 'model' = the extractor's answer, having passed that same check. Null where reference_number is null." }
        order_reference: { type: [string, "null"], description: "MOR-1108. The QUOTE or ORDER this document refers back to, where its 'Referenz:' line names one — 'Referenz: Offerte OF-26-1188, Umgebung Neubepflanzung' yields 'OF-26-1188'. It is neither the payment reference nor the invoice number, and it exists because narrowing reference_number to payment references would otherwise have dropped a value a client asked for by name. NEW from 2026-09-04, forward only: absent on documents processed before that date, which is 'not read yet' rather than 'the document names none'." }
        order_reference_text: { type: [string, "null"], description: "MOR-1108. The referenced line as the document PRINTS it — 'Offerte OF-26-1188, Umgebung Neubepflanzung'. order_reference is what a system matches on; this is the half that tells a person what the order was for, and losing it was the reason the whole line had to be kept rather than only the identifier." }
        order_reference_kind: { type: [string, "null"], enum: [offerte, bestellung, auftrag, unspecified, null], description: "MOR-1108. Which kind of preceding document order_reference points at, where the page says which. 'unspecified' means a bare 'Referenz:' line with no order word beside it — the reference is real, its kind is not stated. Null where order_reference is null." }
        deduction_floor_waived: { type: [object, "null"], properties: { deduction: { type: number }, pre_deduction: { type: number }, label: { type: string } }, description: "MOR-1435. Present when line_items carry a deduction row UNDER the 100.00 floor MOR-1141 keeps against chance matches — taken because the page prints the whole equation: the stored subtotal and VAT are both printed, their sum is the printed pre-deduction figure, the deduction is printed and above a rounding (> 0.05), and subtotal + VAT − deduction = total to the rappen (Lang Energie: 57.22 + 4.63 − 'Alter Saldo zu Ihren Gunsten' 49.35 = 12.50). label is the page's wording. Absent otherwise." }
        printed_line_amounts: { type: [object, "null"], properties: { rows_repaired: { type: array, items: { type: object, properties: { index: { type: integer }, reason: { type: string, enum: [wrong_quantity_column, printed_discount] }, from: { type: object, properties: { quantity: { type: number }, total: { type: number } } }, to: { type: object, properties: { quantity: { type: number }, total: { type: number }, discount_pct: { type: number } } }, line: { type: integer } } } }, levies_appended: { type: array, items: { type: object, properties: { description: { type: string }, total: { type: number }, line: { type: integer } } } } }, description: "MOR-1434. Present when line_items were corrected against each row's OWN printed line: a quantity taken from the wrong column (quantity × unit price printed nowhere, while k × unit price is the line's printed amount — E. Weber: 120 × 50.31 → 2 × 50.31 = 100.62), a printed line discount the row ignored ('250.20 10% 225.18' → total 225.18, discount_pct 10), and printed levy lines ('VOC Abgabe', 'LSVA Abgabe') appended as rows. Written only when the corrected rows plus the levies equal the stored subtotal to the rappen. Absent otherwise." }
        qr_vat_payload: { type: [object, "null"], properties: { closes: { type: string, enum: [exact, rounding_5, rate_only] }, written: { type: array, items: { type: string } }, from: { type: object } }, description: "MOR-1436. Present when the Swiss QR bill's Swico strip (//S1/…/32/…) corrected the VAT: rate:net pairs whose derived gross closes on total_amount exactly or through Swiss 5-rappen rounding write vat_amount (= total − Σ net), subtotal_amount (Σ net, subtotal_source qr_payload), vat_rate (one rate) and vat_breakdown; a bare rate writes vat_rate where none was stored (closes rate_only). written lists the keys changed; from holds what the model had. A payload that does not close is ignored. Absent otherwise." }
        service_vat_table: { type: [object, "null"], properties: { closes: { type: string, enum: [exact, rounding_5] }, written: { type: array, items: { type: string } }, from: { type: object } }, description: "MOR-1444. Present when the page's per-service summary table (Leistung … Betrag MWST % MWST CHF Total CHF, one line per service, closed by a Betrag line) corrected the VAT: every line closes on itself (net + VAT = gross; net × rate within 5 rappen of the VAT), the closing line equals the column sums, and its gross closes on total_amount exactly or through Swiss 5-rappen rounding. Then vat_amount (the closing VAT), subtotal_amount (the closing net, subtotal_source service_table), vat_breakdown (one bucket per rate, summed across lines; only with two or more rates) and a missing vat_rate are written where they differ. written lists the keys changed; from holds what was there before. Absent otherwise." }
        customs_import_vat: { type: [object, "null"], properties: { import_vat: { type: number }, evidence: { type: string }, positions_read: { type: integer } }, description: "MOR-1466: positions_read is present only when the model returned no line items and the invoice's own position table (Dienststelle · Datum · Zusatzinformation · BA-Nr. · Betrag CHF, closed by Total zu unseren Gunsten whose figure equals the rows and the stored total) was read off the page; it counts the rows written, each without a VAT split. MOR-1459. Present on a customs import-VAT invoice (the customs authority BAZG / EZV, `MWST - Rechnung`, and `Gesamtbetrag MWST [CHF]` equal to total_amount): the whole amount IS the import VAT and no VAT is charged on top, so vat_amount is 0.00 (vat_amount_source derived), vat_rate and vat_breakdown are null, subtotal_amount equals the total and the rows carry no VAT split. import_vat is the printed import VAT (reclaimable input tax); evidence is the line it was read from. Absent otherwise." }
        service_table_rows: { type: [object, "null"], properties: { rows: { type: integer }, replaced_model_rows: { type: object, properties: { count: { type: integer }, sum: { type: [number, "null"] } } } }, description: "MOR-1448. Present when the rows were replaced by the lines of the per-service summary table (see service_vat_table: every line closes on itself, the closing line on the column sums, its gross on total_amount). One row per line: total = the line's gross, vat_net = its net (Betrag, or Netto where Akonto columns print), vat_rate = its rate; quantity and unit null; amounts_include_vat true. Written only where the model's rows differed (count, or a gross or net to the rappen); replaced_model_rows describes what was there (sum null when a row had no amount). A 5-rappen difference between the table and the total is recorded as a Rundung adjustment. Absent otherwise." }
        section_totals: { type: [object, "null"], properties: { fired: { type: boolean }, rows_read: { type: integer }, grand: { type: object, properties: { label: { type: string }, amount: { type: number } } }, closes_on: { type: string, enum: [printed_line, net, gross] }, rounding_from_printed_totals: { type: number }, replaced_model_rows: { type: object, properties: { count: { type: integer }, sum: { type: [number, "null"] }, rows_without_amount: { type: integer } } } }, description: "MOR-1418. Present when line_items are ONE ROW PER SECTION of a bill of sections (Sunrise: three subscribers, each with its own 'Total Mobile', then the printed sum) — because the model returned no rows, or rows whose sum the page prints nowhere (replaced_model_rows, per MOR-1400's rule). MOR-1433: the anchors are 'Total <label>', 'Zwischentotal', 'Total je Vertrag/Profil', '<…>summe <no.> Gesamt', also with the amount on the next line; closes_on says what the run closed on — a printed sum line (printed_line), the document's net with no sum line printed (net: the stored subtotal, else total − VAT), or its total (gross); a model sum equal to ONE section's total gives way too (replaced_model_rows). rounding_from_printed_totals is the invoice amount minus the printed sum (at most five rappen), the rounding the page shows by two totals rather than by a line; it is also written as an adjustment. Rows are gross where the sum is subtotal + VAT (amounts_include_vat). Absent otherwise." }
        section_block: { type: [object, "null"], properties: { fired: { type: boolean }, rows_read: { type: integer }, header: { type: object, properties: { label: { type: string }, amount: { type: number } } }, closes_on: { type: string, enum: [total, total_minus_rounding, gross] }, replaced_model_rows: { type: object, properties: { count: { type: integer }, sum: { type: [number, "null"] }, rows_without_amount: { type: integer } } } }, description: "MOR-1415. Present when line_items were read off a SECTION BLOCK — a header figure ('Abonnemente 399.68') followed by the lines that sum to it — because the model returned no rows, or rows whose sum the page prints nowhere (replaced_model_rows says what was replaced, per MOR-1400's rule). closes_on says what the header closed on: the total, the total through a printed rounding line, or subtotal + VAT (the lines are then gross — see amounts_include_vat). Absent otherwise." }
        line_items: { type: [array, "null"], items: { $ref: "#/components/schemas/LineItem" }, description: "Key on 31'436 documents: an ARRAY on 8'832, JSON null on 22'604. Null means no positions were read, not that the document has none." }
        valid_until: { type: [string, "null"], description: "ISO date. 31'278 documents." }
        betreff: { type: [string, "null"], description: "The document's own subject line, as printed. 25'066 documents. MOR-1422 (F44): where the classifier answered nothing or a generic label ('Sonstiges Dokument'), the file name without the import's UUID and the extension stands in. In the scayla_v1 profile it is additionally surfaced as `Subject`." }
        filename_hints: { type: [array, "null"], items: { type: string }, description: "MOR-1422 C4. Status words and abbreviations the FILE NAME carries — def, prov, entwurf, retour, unterzeichnet, kopie (lower-case) and MV, SR, WAP, BR, KB, BA (upper-case) — parsed from the name the person gave the file. Present only when there are any. Measured 2026-09-15 on the analysed organisation: 1'877 of 15'855 names carry one (MV 660, def 462, BR 284, WAP 157, prov 131, KB 116). Parsed and measured in wave 2; no reader consumes them before wave 5." }
        date_issued: { type: [string, "null"], description: "ISO date the document states. NOT the upload date. 24'996 documents." }
        date_issued_source: { type: [string, "null"], enum: [qr_slip_page, letterhead, labelled_over_due, null], description: "Where date_issued came from when it was not the model's answer. labelled_over_due (MOR-1414): the model answered a date the page prints under a DUE label; the date under the issue-date label (or the letterhead) replaced it — see date_issued_was_due_date. qr_slip_page (MOR-1166): the QR slip's own page printed a labelled date. letterhead (MOR-1422): the model returned no date, or a date the document prints nowhere, and the letterhead line ('Luzern, 11. Juni 2024', 'Nyon, den 16.07.2025', '12-Mar-2024') was read deterministically. Absent/null when the model's date stood." }
        date_issued_not_in_text: { type: [object, "null"], properties: { model: { type: string } }, description: "MOR-1422 (F17). Present when the model answered a date_issued the document prints in NO format — numeric, ISO, spelled month, or glyph-per-run. That date was dropped: date_issued is the letterhead's date where one exists, otherwise null and the document carries review reason RT18. `model` is the dropped value, DD.MM.YYYY." }
        date_issued_was_due_date: { type: [string, "null"], description: "MOR-1414. The model's date_issued when it was one of the page's printed DUE dates and was replaced by the labelled issue date (date_issued_source = labelled_over_due). DD.MM.YYYY. Absent otherwise." }
        vat_zero_printed: { type: [string, "null"], description: "MOR-1414. The VAT line the page prints with 0.00 ('Mwst.: 0.00') when the model returned no VAT, no other VAT line prints an amount and gross = net — vat_amount is then '0.00' with vat_amount_source extracted. Measured 2026-09-16: 44 documents print such a line with vat_amount null. Absent otherwise." }
        received_date: { type: [string, "null"], description: "MOR-1422. The receipt STAMP the document prints — 'EINGEGANGEN 21. AUG. 2024', 'Eingang: 12.03.2024', 'Reçu le 3 mars 2024' — as DD.MM.YYYY. A fact about the receiver's mailroom, never a candidate for date_issued. Forward only from the wave-2 promotion; measured 2026-09-15: 1'047 of 15'855 documents of the analysed organisation print one." }
        pageCount: { type: [number, "null"], description: "21'128 documents, always a number. camelCase for historical reasons — it is not renamed because a client reads it." }
        booking_date: { type: [string, "null"], description: "ISO date. 12'460 documents." }
        period_from: { type: [string, "null"], description: "ISO date. 5'382 documents." }
        period_to: { type: [string, "null"], description: "ISO date. 5'301 documents." }
        vat_rate: { type: [string, "null"], description: "A single rate as a percentage, ALWAYS a string where present. Key on 4'455 documents: a value on 1'512, JSON null on 2'943. Where a document prints a per-rate recapitulation, vat_breakdown carries it and this field does not summarise it." }
        valid_from: { type: [string, "null"], description: "ISO date. 3'586 documents." }
        imageType: { type: [string, "null"], description: "1'710 documents. camelCase for historical reasons." }
        headings: { type: [array, "null"], items: { type: string }, description: "Document headings, as strings. 700 documents." }
        sheets: { type: [array, "null"], items: { type: string }, description: "Spreadsheet sheet names. 337 documents." }
        empty_content: { type: [boolean, "null"], description: "The file carried no readable text. 84 documents, always a boolean." }
        alternatives: { type: [array, "null"], items: { type: object }, description: "Runner-up classifications. 34 documents." }
        vat_amount: { type: [string, "null"], description: "VAT in the document's currency, as a string. NEVER present without vat_amount_source — an amount is never stored without its source. Key on 15 documents, of which 11 hold a value and 4 hold JSON null. The count is small because the historical backfill was deliberately NOT run (MOR-1001): age, not failure." }
        vat_amount_source: { type: [string, "null"], enum: [extracted, derived, null], description: "How vat_amount was obtained. `extracted` = the document stated it. `derived` = computed from a rate and a net. Travels with vat_amount always, so a client never has to guess whether a figure was read or calculated. 15 documents." }
        vat_amount_repaired_from: { type: [string, "null"], description: "MOR-1432. What vat_amount was BEFORE a printed management-fee line corrected it, or null. A Nebenkostenabrechnung can print '3.5% Verwaltungshonorar zzgl. 8% MWST 108.25' — the fee WITH its own VAT — and the model reads that figure as the document's VAT recapitulation. The repair fires only when the fee line's own arithmetic closes: the printed amount equals the fee rate times a base the page also prints as rows, times one plus the VAT rate, to the nickel. Present only alongside vat_source management_fee_line." }
        vat_source: { type: [string, "null"], enum: [management_fee_line, null], description: "MOR-1432. Where vat_amount came from when it did NOT come from the model, or null — which is every document the model answered correctly for. The only value today is management_fee_line: a Nebenkostenabrechnung's management fee is billed WITH its own VAT ('3.5% Verwaltungshonorar zzgl. 8% MWST 108.25'), which the model reads as the document's VAT line. This is arithmetic on the printed fee line, never a guess: the fee row itself is untouched (see MOR-1416's vat_row_removed_from, which is a DIFFERENT repair and never fires on the same document) and only vat_amount and subtotal_amount move, to the VAT on the fee alone." }
        management_fee_repair: { type: [object, "null"], description: "MOR-1432 (2026-09-17). Present only beside vat_source = management_fee_line. Which proof accepted the fee figure and the numbers it turned on: proof is fee_arithmetic when the printed percentage times a base printed as rows closes on the fee to +/-0.05, or labelled_row_in_complete_table when it does not and the figure is instead a row of the stored table carrying the fee's own label inside a table that sums to the document total. The second exists because the printed percentage is not reliable: measured across the 98 production documents carrying this defect on 2026-09-17, the rate implied by the fee figure runs 0.35 % to 15.66 % against a printed 3-3.5 % (003c3def prints 3.5 % and charges exactly 3.0 %). base is the net the arithmetic closed on, or null under the second proof, which consults no base. row_index is the fee row in line_items, or -1 where the model returned no row for it (32 of the 98)." }
        slides: { type: [array, "null"], items: { type: string }, description: "Presentation slide titles. 8 documents." }
        ai_skipped: { type: [boolean, "null"], description: "Extraction was not attempted. 2 documents." }
        ai_skip_reason: { type: [string, "null"], description: "Why. 2 documents." }
        amounts_include_vat: { type: [boolean, "null"], description: "TRUE when the document states its position amounts INCLUDE VAT (\"inkl. MwSt\", \"TTC\"), FALSE when it states they exclude it, null when it says neither — which is most documents. Never inferred from the figures: that reading is made at display time, where it can be revised, rather than written into the record as though the document had said it. New 2026-08-26; forward only, no backfill." }
        vat_breakdown: { type: [array, "null"], items: { $ref: "#/components/schemas/VatBreakdownRow" }, description: "The document's own per-rate VAT recapitulation, in the order the document prints it. Key on 2 documents: an ARRAY on 1, JSON null on 1. Same reason as vat_amount — no backfill was run." }
        adjustments: { type: [array, "null"], items: { $ref: "#/components/schemas/DocumentAdjustment" }, description: "MOR-1289. What the document prints BETWEEN its positions total and its final total, in the document's own order: Rabatt, Skonto, Anzahlung/Akontozahlung, Gutschrift, Rundung, Versandkosten. Each row carries the document's OWN wording as `label` and an `amount` SIGNED AS PRINTED — a deduction is negative, a surcharge positive. These are not positions and not VAT: no goods correspond to them, they sit after the item list, and a deduction here has usually been taxed on the invoice that raised it. PRINTED ONLY, never computed: where the positions miss the subtotal and the document names no reason, this is null rather than a difference we invented. NEW 2026-09-09 and forward only, so an absent key means \"not read yet\" and a stored null means the document prints none. Measured before it existed: 44 of 244 production documents carrying positions, a total and a stated VAT amount had a deduction visible on the page and nowhere in the data." }
        qr_reference: { type: [string, "null"], description: "The Swiss QR bill reference, as the parser read it. The value is canonical; the space grouping seen in the interface is a printing convention and is not part of it. 1 document." }
        qr_reference_type: { type: [string, "null"], enum: [QRR, SCOR, null], description: "Which standard qr_reference follows. 1 document." }
        invoice_number: { type: [string, "null"], description: "MOR-1080. The number the SUPPLIER calls this document, read off the page by its printed label (Rechnungsnummer / Belegnummer / RG-Nummer). Distinct from reference_number, which prefers the QR reference a BANK matches a payment on — on a document carrying a QR bill those are different values and this field is the one an accountant keys into a ledger. Null where the page prints no label and a QR bill has taken reference_number: 'not known' rather than the payment reference." }
        contract_number: { type: [string, "null"], description: "MOR-1258. The contract/policy number the document prints under its own label (Vertrag Nr. / Vertrags-Nr. / Vertragsnummer / Police Nr. / Contrat n° / Contratto n.). A third field, distinct from both reference_number (the bank's payment reference, MOR-1005) and invoice_number (MOR-1080): 163 production documents print a Vertrag Nr. and NONE of them also carry an invoice_number, so the two never compete. Never derived from reference_number even where the digits overlap — a QR reference can embed the same digits the contract number does and is still a different value. Null where the page prints none of the labels above." }
        contract_number_source: { type: [string, "null"], enum: [labelled, null], description: "MOR-1258. How contract_number was read, on the principle that a value and its provenance travel together (MOR-1001). 'labelled' is the only tier today: the number printed beside its own label. Null where contract_number is null." }
        vat_number: { type: [string, "null"], description: "MOR-1081. The SUPPLIER's Swiss UID, canonical CHE-123.456.789. Asked for by name by a client and never built: 8050 of 32939 completed production documents already print one in text we store and nothing looked for it. Validated by its mod-11 check digit, so a nine-digit run that merely looks like a UID is rejected arithmetically. Null where the page prints none, or prints several with no way to tell whose is whose - a UID can belong to the sender OR the receiver, and storing the wrong one is worse than storing none because it is what an accountant posts from." }
        vat_number_source: { type: [string, "null"], enum: [sole, sender-adjacent, recipient-excluded, null], description: "MOR-1460: recipient-excluded — several numbers on the page, the ones labelled as the recipient's (Ihre / Kunden / Empfänger … MWST/UID Nr., the customs Sped-Nr./TIN/UID) excluded, exactly one left. MOR-1081. How vat_number was determined. 'sole' is the only UID on the page, which is 7228 of the 8050 that print one. 'sender-adjacent' is one of several, resolved by whose name it sits beside, and is the weaker read - a reviewer may reasonably want to see which it was." }
        total_amount_repaired_from: { type: [string, "null"], description: "MOR-1038. What total_amount was BEFORE the page corrected it, or null. Three documents on three organisations stored a total truncated at its thousands separator — 8,475.60 printed, 8.47 stored — which is a well-formed number in the right field, off by a factor of a thousand. The repair fires only when the stored figure is absent from the document's text AND exactly one printed number could have produced it. The original travels with the correction because a repaired figure that looks identical to an extracted one is the defect MOR-1001 exists to prevent. Null on the overwhelming majority, which are never touched." }
        subtotal_amount_repaired_from: { type: [string, "null"], description: "MOR-1131. What subtotal_amount was BEFORE the document's own VAT recapitulation corrected it, or null. On a Sammelrechnung with more than one VAT rate the extraction can read ONE bucket's net as the invoice's: document 7d8b5a62 stored 22'128.57, the net of the 2.5 % rate alone, against an invoice net of 23'662.66. The repair fires only when the breakdown accounts for the whole invoice — sum(net) + sum(VAT) equals total_amount to the centime — AND the stored subtotal equals exactly one bucket's net. Measured over 33'116 production documents: 23 carry a multi-rate breakdown, 3 have a subtotal equal to one bucket, and the reconciliation test separates the 1 that is wrong from the 2 where the subtotal is right and the breakdown is not. The original travels with the correction for the same reason total_amount_repaired_from does (MOR-1001). Null on all but one document. MOR-1384 (2026-09-13): also written when subtotal_source is paired_with_invoice_number — then it holds the derived net that the paired total disproved (383.88 on c3bcbb50), whether the subtotal was rewritten or cleared." }
        scale_repaired_from: { type: [object, "null"], additionalProperties: { type: string }, description: "MOR-1164. What total_amount, subtotal_amount and vat_amount held BEFORE the x1000 repair, keyed by field, or null. A thousands separator read as a decimal point divides the figure by a thousand while keeping every digit: seed-0156 printed 43 057.48 / 39 831.16 / 3 226.32 with a non-breaking space and stored 43.05748 / 39.83116 / 3.22632. Its POSITIONS were correct and summed to the printed net, so the line-items gate passed and the reconciliation read ok — nothing else in the pipeline had a reason to object. The repair fires only where the page PRINTS the x1000 result AND does NOT print the stored value, and it never computes a figure or derives one field from another. Measured over production: five documents carry the fingerprint. The original travels with the correction for the same reason total_amount_repaired_from does (MOR-1001); a single key rather than three *_repaired_from fields, because those already carry other repairs and overloading them would make 'which repair wrote this?' unanswerable." }
        subtotal_source: { type: [string, "null"], enum: [column_table, ledger, management_fee_line, paired_with_invoice_number, printed_net, qr_payload, service_table, null], description: "MOR-1444: service_table when the net is the closing line of a per-service summary table (Leistung … Betrag MWST % MWST CHF Total CHF) that closes line by line, on its column sums and on total_amount. MOR-1436: qr_payload when the net is the sum of the Swiss QR bill's /32/ rate:net pairs and no reader wrote one. MOR-1187 / MOR-1384 / MOR-1400 / MOR-1432. Where subtotal_amount came from when it did NOT come from the model, or null — which is every document the model answered for. The only value today is column_table: a two-column amount table whose labels and amounts pair one for one and whose chain (gross − deduction = net, net + VAT = total) closes onto the document's own stated total. 37da3dc4 stored 8484.20 on 2026-09-05 and null after the 2026-09-07 reprocess — the model simply did not return a subtotal that run, nothing in the pipeline derived one, and the gate meanwhile derived its own net and quoted it in the failure message, so the page said 8484.20 while the field said nothing. Strictly a fallback: a subtotal the model returned is never overwritten. MOR-1384 adds paired_with_invoice_number: on a split bundle child whose total MOR-1376 replaced (see total_amount_source), the stored subtotal was the model's bundle-total-minus-VAT arithmetic; it is rewritten to the paired total minus VAT only when the positions or the page corroborate that figure, and cleared otherwise. MOR-1400 adds ledger: on a document whose positions the ledger reader wrote (an Abrechnung or a Schlussrechnung read off its running totals), the net is the printed running total that equals the sum of the positive rows — two printed facts agreeing, no label. Written when the model returned no subtotal or one the page prints nowhere (764fb960: 12000.00 cleared, 95640.00 written). Strictly a fallback, like column_table. MOR-1416 (2026-09-15): printed_net when the model gave no subtotal and the page printed the net beside the VAT row that was removed (MWST Basis 109.00 on 8ed008ea); see vat_row_removed_from. MOR-1432 (2026-09-16) adds management_fee_line: a Nebenkostenabrechnung's management fee is billed WITH its own VAT ('3.5% Verwaltungshonorar zzgl. 8% MWST 108.25'), which the model reads as the document's VAT line and the total minus that figure as the net — this REPLACES a subtotal the model DID return, unlike every value above, because the fee line's own arithmetic proves the stored one wrong rather than merely filling an absence; see vat_source." }
        invoice_number_source: { type: [string, "null"], enum: [labelled, qr_message, title, positional, bare_label, settlement, swico, regional, model, null], description: "MOR-1080. How invoice_number was read, on the principle that a value and its provenance travel together (MOR-1001). The readers run in this order and the first to answer wins, so the value names the strongest source that could see this page: 'labelled' is the number printed beside its own label; 'qr_message' is the QR bill's free-text message; 'title' is a heading line; 'positional' is an alignment across a column header — sound but weaker, and a reviewer may reasonably want to see which it was; 'bare_label' is a bare Rechnung and its next token, accepted only on six digits or more; 'settlement' (MOR-1461) is an Abrechnung Nr., which is the document's identifier only where no invoice number is printed at all; 'swico' (MOR-1483) is tag /10/ of the QR slip's //S1/ strip, written by the issuer's software rather than printed on the page, and it runs last because where the two differ the printed number is the one a person searches for; 'regional' is a locale-specific reader; 'model' is the extractor's reference_number where no QR bill claimed it and no reader answered. This enum was corrected in MOR-1483: it had listed three of the ten values since MOR-1080, and five readers added after it were absent." }
        invoice_number_rejected_from: { type: [string, "null"], description: "MOR-1471. What invoice_number would have held, when it was refused because it is the number printed under an Abrech.-Nr / Abrechnungs-Nr label — a social insurer's account number (see contract_number, MOR-1468), not the document's own number. Absent on every document where nothing was refused. The value is kept rather than dropped, on MOR-1137's rule that a refused value is never deleted in silence. Cause measured 2026-09-17: findInvoiceNumber's own label Rechnungs-Nr matches as a substring of Ab-rechnungs-Nr; a word boundary was rejected as the fix because 117 documents print a label glued to a preceding letter and most are real invoice numbers (Sammelrechnungs-Nr, Teilrechnung Nr, Schlussrechnung Nr)." }
        total_amount_source: { type: [string, "null"], enum: [paired_with_invoice_number, null], description: "MOR-1376. Present only when total_amount did NOT come from the model: paired_with_invoice_number means the page is a bundle overview (Summe Zahlbetrag, Rechnungsübersicht) and the stored total was the bundle sum, so the amount printed beside this document's own invoice number was taken instead. Measured on the Swiss half of a split BMW Charging bundle: 409.43 stored, 341.09 paired, lines + VAT = 341.09. Absent everywhere else, which is every document whose total the model read correctly." }
        vat_country: { type: [string, "null"], enum: [DE, AT, CH, FR, IT, LI, null], description: "MOR-1377. The country printed beside the VAT rate (19% MwSt. DE), read deterministically off the page. Never guessed from currency or sender. Absent on an invoice that names none, which is every Swiss one; the reconciliation engine's R9 uses it to judge the rate against that country's law instead of Switzerland's." }
        credit_balance: { type: [string, "null"], description: "MOR-1256. The settlement balance the page prints beside its total, or null. A Nebenkostenabrechnung nets its total against what was already paid; total_amount is still the total, and this is what is left. Read only where an amount sits directly beside the label - a positional reading was written, measured over 1600 production documents and DELETED, because the corpus prints the figure on both sides of the label and answered the Total Nebenkosten on one layout and the Akonto on its mirror. Null on 1019 of those 1600, which is the reader declining rather than guessing." }
        credit_balance_direction: { type: [string, "null"], enum: [customer, issuer, null], description: "MOR-1256. Whose favour credit_balance is in. 'customer' means the reader is OWED and nothing is payable; 'issuer' means the reader OWES it, and the document is still due. The page states this in one word - Saldo zu IHREN Gunsten against zu UNSEREN Gunsten - and 595 of production documents print BOTH phrases, so a direction is only recorded when an amount is attached to it and a page attaching one to each records neither. Measured: 234 customer, 347 issuer over 1600 documents carrying the phrase." }
        credit_balance_source: { type: [string, "null"], enum: [adjacent, null], description: "MOR-1256. How credit_balance was read, on the principle that a value and its provenance travel together (MOR-1001). 'adjacent' - the amount hanging off the right of the label - is the only reading that survived measurement." }
        qr_amount_present: { type: boolean, description: "MOR-1256. Whether the Swiss QR payment slip printed an amount. false is the variable-amount slip, the issuer saying no fixed sum is due - the document that opened MOR-1256 showed CHF 11820.95 due beside a blank Betrag and a printed balance of 4679.05 in the reader's favour. ABSENT rather than false where the document carries no slip at all: false is a statement about a slip that was read, absence is a statement that there was none." }
        has_qr_bill: { type: boolean, description: "MOR-1255. Whether the page carries a Swiss/Liechtenstein QR-bill payment slip heading (Empfangsschein/Zahlteil/Payment part/Recepisse/Section paiement, in whichever of the four languages the standard prints it in). A flag and a filter only - it is NEVER used to reclassify a document. A reminder or a debt-collection notice carries a slip too, for reasons that are not 'this is an invoice'; doc_type/classification are untouched by this value. Recomputed on every run, ingest and reprocess alike." }

    LineItem:
      type: object
      description: >
        One position of an invoice, as printed. Shape verified against a
        production row on 2026-08-26 rather than against the extractor's prompt.
      properties:
        position: { type: [number, "null"], description: "1-based order as printed" }
        description: { type: [string, "null"] }
        quantity: { type: [number, "null"] }
        unit: { type: [string, "null"], description: "As printed — Stk, h, Pl, m2" }
        unit_price: { type: [number, "null"] }
        total: { type: [number, "null"] }
        vat_rate: { type: [number, "null"], description: "VAT rate attached to THIS position, as a percentage, where the document attaches one there. Null means the document did not print a rate on this line — never that the rate is zero. 0.0 is a real rate (reverse charge, exempt supply) and is not the same as null. New with MOR-1000 slice 2b; every existing position carries null, and no archive backfill was run." }
        vat_net: { type: [number, "null"], description: "The net (Bemessungsgrundlage) this position's rate was applied to, where the document prints it. Never computed from vat_amount — a computed figure is not an extracted one." }
        vat_amount: { type: [number, "null"], description: "VAT for this position. CHECKED before storage since 2026-08-26: where the line also carries a gross total and a net, `total - vat_net` is computed and compared, because a measured document had two of five positions arithmetically wrong (8.09 where the line's own figures give 8.76). See vat_amount_source." }
        vat_amount_source: { type: [string, "null"], enum: [extracted, derived, null], description: "`extracted` = the figure the document printed and it checks out. `derived` = the document's figure disagreed with its own net and gross, so this is `total - vat_net`. Subtraction of two printed numbers, never an inference about what the document meant." }

    VatBreakdownRow:
      type: object
      description: One line of a document's own VAT recapitulation.
      properties:
        rate: { type: number, description: "Percentage, e.g. 8.1" }
        amount: { type: number, description: "VAT for this rate, in the document's currency" }
        net: { type: [number, "null"], description: "The net amount this rate was applied to, where the document printed it. The key is `net`, not `base`." }

    DocumentAdjustment:
      type: object
      description: "MOR-1289. One line the document prints between its positions total and its final total."
      properties:
        label: { type: string, description: "The document's own wording, verbatim — \"Rabatt 5 % Stammkunde\", \"Akontozahlungen per 30.06.2026\", \"Rundung auf 5 Rappen\". Kept rather than reduced to a type, because to a bookkeeper an Akontozahlung and a Rabatt are different facts and both would collapse into one word." }
        amount: { type: number, description: "Signed as the document prints it. A deduction is negative, a surcharge positive. An unsigned magnitude would leave the direction to the reader." }
      required: [label, amount]
    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]
          deprecated: true
          description: >-
            DEPRECATED (MOR-1426). A model guess, asked only for doc_type=rechnung and
            clamped to `sonstiges` on anything unexpected — nothing on an invoice says
            whether it was paid. Measured over 33'161 production documents on 2026-09-18:
            7'483 invoices carry `offen`, and 4'637 of those were already more than 90 days
            past due on the day they were uploaded. It is superseded by the derived
            `payment_status` (open | assumed_paid_by_age | paid | cancelled |
            not_applicable) with `payment_status_source` beside it. This field is still
            written and still filterable; it will not be removed without notice, and where
            the two disagree the derived one is the one computed from printed dates.
        tags: { type: array, items: { type: string }, description: "Replaces the full tag set." }
