openapi: 3.1.0
info:
  title: SeveUp API
  version: 1.18.0
  summary: Projets, maquettes IFC, révisions, contrôles IDS et exports de SeveUp App.
  description: |
    API publique de SeveUp App, réservée aux plans PRO, BUSINESS, ENTREPRISE et CUSTOM (TRIAL : lecture seule).

    **Authentification.** Une clé d'organisation `sk_…`, créée dans la console d'organisation (section « Clés d'API »),
    envoyée en `Authorization: Bearer sk_…`. Chaque clé porte des scopes ; une route sans le scope requis répond `403`
    avec `code: SCOPE_MISSING`. Aucun secret n'est accepté dans une URL.

    **Erreurs.** Toujours `application/problem+json` (RFC 9457). Le champ `code` est stable : le programme s'appuie
    dessus, jamais sur `title` ou `detail`. Le `type` s'en déduit (`SCOPE_MISSING` donne
    `https://seve-up.com/problems/scope-missing`). Une requête invalide (`VALIDATION`) détaille chaque champ fautif dans
    `errors`. Un identifiant inconnu, mal formé ou d'une autre organisation répond `404` `NOT_FOUND`.

    **Pagination.** Par curseur opaque : `?limit=` (1 à 200, 50 par défaut) et `?cursor=` (valeur de `next_cursor`
    de la page précédente). Fin de liste : `has_more: false` et `next_cursor: null`.

    **Plans.** L'API est ouverte aux plans PRO, BUSINESS, ENTREPRISE et CUSTOM. Le plan d'essai (TRIAL) l'ouvre en lecture
    seule : toute écriture répond `403` `PLAN_READ_ONLY`. Les autres plans répondent `403` `PLAN_REQUIRED`. Un plan
    payant arrivé à échéance reste lisible ; ses écritures répondent `PLAN_READ_ONLY`.

    **Débit.** Chaque réponse porte `RateLimit-Policy` et `RateLimit` (draft-ietf-httpapi-ratelimit-headers), par clé et
    par fenêtre d'une minute : 60 requêtes en essai, 600 en PRO, 1 200 en BUSINESS, 3 000 en ENTREPRISE et CUSTOM.
    Au-delà : `429` `RATE_LIMITED` avec `Retry-After` en secondes.

    **Quotas.** Par organisation et par mois calendaire (UTC) : appels, versions déposées, volume déposé, contrôles
    IDS. Un quota atteint répond `429` `QUOTA_EXCEEDED`, avec `Retry-After` jusqu'au 1er du mois suivant. Un quota se
    consomme avant le lancement du traitement : une requête refusée ne compte pas. Si la vérification est
    momentanément impossible, l'appel répond `503` `BUSY` plutôt que de passer sans être compté.

    **Capacité.** Les opérations marquées `x-heavy` (dépôt, conversion, contrôle IDS, export, synthèses) partagent un
    nombre limité de places ; une organisation a au plus un nombre fixé de traitements actifs (3 en PRO, 5 en
    BUSINESS, 10 en ENTREPRISE et CUSTOM). Place indisponible : `503` `BUSY` avec `Retry-After`. Trop de traitements en
    cours : `429` `QUOTA_EXCEEDED` avec `Retry-After`.

    **Idempotence.** Tout `POST` accepte `Idempotency-Key` (1 à 200 caractères, gardée 24 h par organisation). Rejouer
    la même clé avec le même corps renvoie la réponse d'origine et `Idempotent-Replayed: true` ; avec un autre corps,
    `409` `IDEMPOTENCY_KEY_REUSED`.

    **Traitements longs.** Conversion d'une maquette, contrôle IDS et export répondent `202` avec une ressource
    `Job` ; suivre `GET /api/v1/jobs/{job_id}` ou s'abonner aux webhooks.

    **Types.** Les nombres et booléens sont de vrais types JSON. Les dates sont en RFC 3339 (UTC).
  contact:
    name: SeveUp
    url: https://www.seve-up.app
    email: contact@seve-up.com
  license:
    name: Propriétaire
    identifier: LicenseRef-SeveUp-Proprietary
servers:
  - url: https://api.seve-up.app
    description: Production
    x-internal: false
security:
  - apiKey: []
tags:
  - name: Organisation
    description: Organisation à laquelle la clé est rattachée.
  - name: Projets
    description: Projets de l'organisation visibles par la clé.
  - name: Espaces
    description: Espaces de l'organisation (regroupements de projets), et ce qui y est rangé.
  - name: Tableaux de bord
    description: Tableaux de bord (modèles de rapports de l'App) d'un espace, et modèles proposés pour en créer.
  - name: Maquettes
    description: Maquettes IFC et leurs révisions.
  - name: Fichiers
    description: Dépôt de fichiers par URL signée, avant création d'une révision ou d'un jeu d'exigences.
  - name: IDS
    description: Jeux d'exigences IDS et contrôles de conformité.
  - name: Exports
    description: Export des données d'une révision.
  - name: Visa
    description: Grille de visa des projets et fiches de visa des maquettes, en lecture.
  - name: Documents
    description: Documents de la bibliothèque visibles depuis un projet, leurs versions et leurs fichiers, en lecture.
  - name: Kairnial
    description: Projets du connecteur Kairnial et exports zip de leurs fichiers.
  - name: Issues
    description: Points à traiter sur les maquettes (issues BCF de l'App) et leurs commentaires.
  - name: Jobs
    description: Suivi des traitements longs.
  - name: Webhooks
    description: Abonnements aux événements de l'organisation.

paths:
  /api/v1/organization:
    get:
      operationId: getOrganization
      summary: Lire l'organisation de la clé
      description: |
        Organisation à laquelle la clé est rattachée : son identifiant et son nom, rien d'autre. Une clé ne voit que
        cette organisation ; une ressource d'une autre organisation répond `404`. Un agent s'en sert pour répondre
        qu'une demande vise une autre organisation, hors de portée de la clé, plutôt que de la chercher. Le nom peut
        être `null` s'il n'a jamais été renseigné.
      tags: [Organisation]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: L'organisation de la clé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organization" }
              example:
                id: 2f6d1c8a-4b3e-4f7a-9c2d-8e5b1a0f3d47
                name: SNCF Réseau — LNPCA
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects:
    get:
      operationId: listProjects
      summary: Lister les projets
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: q
          in: query
          description: Filtre sur le nom (contient, sans casse).
          schema: { type: string, maxLength: 200 }
      responses:
        "200":
          description: Une page de projets.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Project" }
              example:
                data:
                  - id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                    name: LNPCA — Section 1
                    description: Ligne nouvelle Provence Côte d'Azur
                    space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                    model_count: 197
                    requirement_set_count: 4
                    start_date: "2026-01-05T00:00:00Z"
                    end_date: null
                    created_at: "2026-01-05T09:12:44Z"
                    updated_at: "2026-10-20T16:02:10Z"
                next_cursor: eyJpZCI6IjViMGYzYzNlIn0
                has_more: true
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    post:
      operationId: createProject
      summary: Créer un projet
      description: |
        Projet créé au nom du créateur de la clé, dans l'espace `space_id` (à la racine de l'organisation sinon). Un
        nom déjà pris dans le même espace (sans tenir compte de la casse) répond `409`.
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectCreate" }
            example:
              name: LNPCA — Section 2
              description: Ligne nouvelle Provence Côte d'Azur
              space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
              start_date: "2027-01-04T00:00:00Z"
      responses:
        "201":
          description: Projet créé.
          headers:
            Location:
              description: URL du projet.
              schema: { type: string }
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
              example:
                id: 6c1a4d4f-2e8b-4e1d-8b62-4a3c9f2e8b21
                name: LNPCA — Section 2
                description: Ligne nouvelle Provence Côte d'Azur
                space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                space_name: SNCF
                space_path: ~/SNCF
                model_count: 0
                requirement_set_count: 0
                start_date: "2027-01-04T00:00:00Z"
                end_date: null
                created_at: "2026-11-04T09:00:00Z"
                updated_at: "2026-11-04T09:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProject
      summary: Lire un projet
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: Le projet.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
              example:
                id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                name: LNPCA — Section 1
                description: Ligne nouvelle Provence Côte d'Azur
                space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                model_count: 197
                requirement_set_count: 4
                start_date: "2026-01-05T00:00:00Z"
                end_date: null
                created_at: "2026-01-05T09:12:44Z"
                updated_at: "2026-10-20T16:02:10Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    patch:
      operationId: updateProject
      summary: Modifier un projet
      description: |
        Seuls les champs présents changent ; `null` vide un champ facultatif (`description`, dates, `space_id` qui range
        le projet à la racine). Renommer vers un nom déjà pris dans l'espace répond `409`, comme un projet synchronisé
        depuis Trimble Connect, qui se modifie dans Trimble.
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUpdate" }
            example:
              name: LNPCA — Section 1 (APD)
              end_date: "2027-06-30T00:00:00Z"
      responses:
        "200":
          description: Le projet modifié.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
              example:
                id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                name: LNPCA — Section 1 (APD)
                description: Ligne nouvelle Provence Côte d'Azur
                space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                model_count: 197
                requirement_set_count: 4
                start_date: "2026-01-05T00:00:00Z"
                end_date: "2027-06-30T00:00:00Z"
                created_at: "2026-01-05T09:12:44Z"
                updated_at: "2026-11-04T09:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      operationId: archiveProject
      summary: Archiver un projet
      description: |
        Comme « Archiver » dans l'App : le projet sort des listes de l'API et de l'App, et se restaure depuis la section
        « Archivés » de la liste des projets. Ses maquettes et jeux d'exigences ne sont ni archivés ni supprimés. Un projet
        déjà archivé répond `404`.
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:write]
      responses:
        "204":
          description: Projet archivé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/models:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: listProjectModels
      summary: Lister les maquettes d'un projet
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de maquettes.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Model" }
              example:
                data:
                  - id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                    name: LNPCA-S1-OA-PONT-0042
                    format: IFC
                    revision_count: 3
                    latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                    created_at: "2026-03-02T10:00:00Z"
                    updated_at: "2026-10-18T08:41:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createModel
      summary: Créer une maquette depuis un fichier déposé
      description: |
        Crée la maquette et sa première révision à partir d'un fichier déposé (`POST /api/v1/uploads` puis
        `complete`). La conversion démarre aussitôt : la réponse est le job de conversion.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelCreate" }
            example:
              name: LNPCA-S1-OA-PONT-0042
              upload_id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/visa-grid:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getVisaGrid
      summary: Lire la grille de visa d'un projet
      description: |
        Critères contrôlés au visa des maquettes du projet, par section (`DIFFUSION`, `STRUCTURATION`, `REFERENCE`,
        `MODELISATION`, `DATA`) et par phase, avec leur coefficient (1 à 3) et le contrôle automatique qui propose un
        statut (`auto_check`), plus les pièces attendues à chaque diffusion. `customized` est faux tant que le projet garde
        la grille par défaut. La grille se modifie dans l'App.
      tags: [Visa]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: La grille.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VisaGrid" }
              example:
                project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                customized: true
                criteria:
                  - id: naming
                    section: DIFFUSION
                    label: Nom du fichier conforme à la convention
                    scope: ALL
                    origin: AUTO
                    auto_check: file_naming
                    weight: 2
                    phases: []
                pieces:
                  - { id: ifc, label: Maquette IFC, pattern: null }
                  - { id: note, label: Note de modélisation, pattern: "*NOTE*.pdf" }
                file_name_pattern: null
                max_file_size_mb: 200
                updated_at: "2026-10-12T09:00:00Z"
                updated_by: julie.martin@exemple.fr
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/visa-sheets:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: listVisaSheets
      summary: Lister les fiches de visa d'un projet
      description: |
        Une fiche par révision de maquette visée, des plus récentes aux plus anciennes : phase, numéro de diffusion,
        état (`DRAFT` brouillon, `SIGNED` signée), verdict (`ACCEPTED`, `ACCEPTED_WITH_OBS`, `REFUSED`) et taux de
        conformité `score` entre 0 et 1 (VSO 1, VAO 0,5, refusé 0, pondérés ; nul si aucun critère n'est évalué). Le
        détail des critères se lit avec `GET /api/v1/projects/{project_id}/visa-sheets/{visa_sheet_id}`.
      tags: [Visa]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: model_id
          in: query
          description: Maquette visée.
          schema: { type: string, format: uuid }
        - name: status
          in: query
          description: "`DRAFT` ou `SIGNED`."
          schema: { type: string, enum: [DRAFT, SIGNED] }
      responses:
        "200":
          description: Une page de fiches.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/VisaSheet" }
              example:
                data:
                  - id: 8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b
                    project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                    model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                    revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    revision_version: 3
                    diffusion_no: 2
                    phase: APD
                    status: SIGNED
                    verdict: ACCEPTED_WITH_OBS
                    score: 0.82
                    created_by: julie.martin@exemple.fr
                    created_at: "2026-10-20T08:00:00Z"
                    updated_at: "2026-10-21T16:30:00Z"
                    signed_by: julie.martin@exemple.fr
                    signed_at: "2026-10-21T16:30:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/visa-sheets/{visa_sheet_id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/VisaSheetId"
    get:
      operationId: getVisaSheet
      summary: Lire une fiche de visa
      description: |
        La fiche avec chaque critère évalué : statut (`VSO` visa sans observation, `VAO` avec observations, `REFUSE`,
        `NC` non contrôlé, `NA` non applicable ; nul tant qu'il n'est pas évalué), commentaire, proposition du contrôle
        automatique (`auto_proposal`, `auto_summary`), pièces trouvées ou manquantes, commentaire global, historique
        (création, signature, réouverture avec son motif) et fiche de la diffusion précédente de la même maquette.
      tags: [Visa]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: La fiche.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VisaSheet" }
              example:
                id: 8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b
                project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                revision_version: 3
                diffusion_no: 2
                phase: APD
                status: SIGNED
                verdict: ACCEPTED_WITH_OBS
                score: 0.75
                created_by: julie.martin@exemple.fr
                created_at: "2026-10-20T08:00:00Z"
                updated_at: "2026-10-21T16:30:00Z"
                signed_by: julie.martin@exemple.fr
                signed_at: "2026-10-21T16:30:00Z"
                global_comment: Reprendre les niveaux avant la diffusion PRO.
                items:
                  - criterion_id: naming
                    section: DIFFUSION
                    label: Nom du fichier conforme à la convention
                    scope: ALL
                    origin: AUTO
                    auto_check: file_naming
                    weight: 2
                    status: VAO
                    comment: Discipline absente du nom.
                    auto_proposal: VAO
                    auto_summary: 1 problème de nommage
                    evidence: null
                pieces:
                  - { piece_id: ifc, label: Maquette IFC, present: true, source: AUTO, detail: null }
                history:
                  - { action: CREATED, by: julie.martin@exemple.fr, at: "2026-10-20T08:00:00Z", reason: null }
                  - { action: SIGNED, by: julie.martin@exemple.fr, at: "2026-10-21T16:30:00Z", reason: null }
                previous_sheet_id: null
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/documents:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: listProjectDocuments
      summary: Lister les documents visibles depuis un projet
      description: |
        Les documents de la bibliothèque que l'App montre depuis ce projet, des plus récents aux plus anciens : ceux de
        l'organisation (`level: organization`), ceux de l'espace du projet ou d'un espace parent (`space`), et ceux du
        projet lui-même (`project`). Chaque document décrit le fichier de sa version la plus récente ; l'historique des
        versions se lit avec `GET /api/v1/documents/{document_id}` et le fichier avec
        `GET /api/v1/documents/{document_id}/download`.
      tags: [Documents]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de documents.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Document" }
              example:
                data:
                  - id: 6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d
                    name: Plan RDC
                    description: Plan du rez-de-chaussée, indice B.
                    type: OTHER
                    level: space
                    space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                    project_id: null
                    version: 2
                    version_count: 2
                    file_name: plan-rdc-B.pdf
                    content_type: application/pdf
                    size_bytes: 482113
                    created_by: julie.martin@exemple.fr
                    created_at: "2026-10-20T08:00:00Z"
                    modified_by: julie.martin@exemple.fr
                    modified_at: "2026-10-22T10:15:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/documents/{document_id}:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      operationId: getDocument
      summary: Lire un document et ses versions
      description: |
        Le document avec toutes ses versions, de la plus récente à la plus ancienne : numéro, nom du fichier, type de
        contenu, taille, auteur et date de dépôt. Seuls les documents de l'organisation de la clé sont lus.
      tags: [Documents]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: Le document.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
              example:
                id: 6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d
                name: Plan RDC
                description: Plan du rez-de-chaussée, indice B.
                type: OTHER
                level: space
                space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                project_id: null
                version: 2
                version_count: 2
                file_name: plan-rdc-B.pdf
                content_type: application/pdf
                size_bytes: 482113
                created_by: julie.martin@exemple.fr
                created_at: "2026-10-20T08:00:00Z"
                modified_by: julie.martin@exemple.fr
                modified_at: "2026-10-22T10:15:00Z"
                versions:
                  - id: 7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e
                    version: 2
                    file_name: plan-rdc-B.pdf
                    content_type: application/pdf
                    size_bytes: 482113
                    created_by: julie.martin@exemple.fr
                    created_at: "2026-10-22T10:15:00Z"
                  - id: 8c9d0e1f-2a3b-4c4d-8e5f-6a7b8c9d0e1f
                    version: 1
                    file_name: plan-rdc-A.pdf
                    content_type: application/pdf
                    size_bytes: 471020
                    created_by: julie.martin@exemple.fr
                    created_at: "2026-10-20T08:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/documents/{document_id}/download:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      operationId: downloadDocument
      summary: Obtenir un lien de téléchargement d'un document
      description: |
        Un lien signé vers le fichier d'une version, la plus récente par défaut, valable une heure. Le lien s'ouvre sans
        clé d'API ; ne le transmettez qu'aux personnes qui peuvent lire ce document. Un fichier de l'ancien stockage, qui
        ne peut pas être servi par un lien, répond 409 : il se télécharge depuis l'App.
      tags: [Documents]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - name: version
          in: query
          description: Numéro de version (`version` d'une entrée de `versions`) ; la plus récente par défaut.
          schema: { type: integer, format: int32, minimum: 1, maximum: 100000 }
      responses:
        "200":
          description: Le lien et la description du fichier.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentDownload" }
              example:
                document_id: 6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d
                version: 2
                file_name: plan-rdc-B.pdf
                content_type: application/pdf
                size_bytes: 482113
                download_url: https://storage.example.com/object/sign/attachments/plan-rdc-B.pdf?token=...&download=plan-rdc-B.pdf
                download_expires_at: "2026-10-22T11:15:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/kairnial-projects:
    get:
      operationId: listKairnialProjects
      summary: Lister les projets Kairnial accessibles
      description: |
        Les projets Kairnial que le créateur de la clé voit dans le connecteur Kairnial de l'App, par nom. `file_count` et
        `size_bytes` comptent la dernière version de chaque fichier, d'une seule extension si `extension` est donnée
        (par exemple `dwg`). La liste vient de la dernière synchronisation du connecteur.
      tags: [Kairnial]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: extension
          in: query
          description: Extension de fichier comptée, sans le point (`dwg`, `pdf`…).
          schema: { type: string, pattern: "^[.]?[A-Za-z0-9]{1,16}$", maxLength: 17 }
      responses:
        "200":
          description: Une page de projets Kairnial.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/KairnialProject" }
              example:
                data:
                  - { project_key: hopital, name: SAS BIM - HOPITAL, file_count: 16, size_bytes: 143276544 }
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/kairnial-exports:
    post:
      operationId: createKairnialExport
      summary: Exporter en zip des fichiers d'un projet Kairnial
      description: |
        Rassemble dans un ou plusieurs zips la dernière version de chaque fichier d'un projet Kairnial ayant l'une des
        extensions demandées, dans tout le projet ou dans un dossier et ses sous-dossiers (`folder_id`). Dans le zip,
        chaque fichier garde le chemin de son dossier Kairnial. Les zips sont coupés vers 100 Mo.

        Kairnial ne sert un fichier qu'avec la connexion d'un utilisateur : l'export tourne avec la connexion Kairnial du
        créateur de la clé (`409` s'il n'en a pas de valide) et ne voit que ses projets. Le travail est asynchrone :
        suivre `GET /api/v1/kairnial-exports/{export_id}` jusqu'à `COMPLETE`. Un seul export à la fois par
        organisation (`409` sinon) ; 5 000 fichiers et 20 Go au plus par export. Un fichier que Kairnial ne rend pas est
        écarté et nommé dans `skipped`. Les zips sont gardés 7 jours.
      tags: [Kairnial]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KairnialExportCreate" }
            example:
              project_key: hopital
              extensions: [dwg]
      responses:
        "202":
          description: Export mis en file ; le suivre par son `Location`.
          headers:
            Location: { $ref: "#/components/headers/Location" }
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KairnialExport" }
              example:
                id: 2b3c4d5e-6f70-4a81-9b2c-3d4e5f6a7b8c
                project_key: hopital
                project_name: SAS BIM - HOPITAL
                extensions: [dwg]
                folder_id: null
                status: QUEUED
                file_count: 16
                done_count: 0
                total_bytes: 143276544
                parts: []
                skipped: []
                error: null
                created_at: "2026-11-05T10:02:00Z"
                started_at: null
                completed_at: null
                expires_at: null
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/kairnial-exports/{export_id}:
    parameters:
      - $ref: "#/components/parameters/KairnialExportId"
    get:
      operationId: getKairnialExport
      summary: Suivre un export Kairnial
      description: |
        L'état de l'export (`QUEUED`, `PROCESSING`, `COMPLETE`, `ERROR`, `EXPIRED` après 7 jours), sa progression
        (`done_count` sur `file_count`) et, une fois `COMPLETE`, ses zips avec un lien signé chacun, valable une heure et
        renouvelé à chaque lecture. Les liens s'ouvrent sans clé d'API.
      tags: [Kairnial]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: L'export.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KairnialExport" }
              example:
                id: 2b3c4d5e-6f70-4a81-9b2c-3d4e5f6a7b8c
                project_key: hopital
                project_name: SAS BIM - HOPITAL
                extensions: [dwg]
                folder_id: null
                status: COMPLETE
                file_count: 16
                done_count: 16
                total_bytes: 143276544
                parts:
                  - index: 1
                    file_name: kairnial-sas-bim-hopital-dwg-1.zip
                    file_count: 11
                    size_bytes: 98213450
                    download_url: https://storage.example.com/object/sign/attachments/kairnial-exports/kairnial-sas-bim-hopital-dwg-1.zip?token=...
                    download_expires_at: "2026-11-05T11:15:00Z"
                  - index: 2
                    file_name: kairnial-sas-bim-hopital-dwg-2.zip
                    file_count: 5
                    size_bytes: 40117231
                    download_url: https://storage.example.com/object/sign/attachments/kairnial-exports/kairnial-sas-bim-hopital-dwg-2.zip?token=...
                    download_expires_at: "2026-11-05T11:15:00Z"
                skipped: []
                error: null
                created_at: "2026-11-05T10:02:00Z"
                started_at: "2026-11-05T10:02:01Z"
                completed_at: "2026-11-05T10:06:40Z"
                expires_at: "2026-11-12T10:06:40Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/models:
    get:
      operationId: searchModels
      summary: Rechercher les maquettes de l'organisation
      description: |
        Cherche une maquette par son nom dans toute l'organisation, sans parcourir les projets un à un. `q` filtre sur
        le nom (contient, sans casse) ; sans `q`, la liste rend toutes les maquettes de l'organisation, y compris celles
        rangées dans aucun projet. Chaque maquette porte les projets qui la contiennent (`projects`, trié par nom) : une
        maquette peut appartenir à plusieurs projets, et deux projets de même nom se distinguent par `space_path`.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: q
          in: query
          description: Filtre sur le nom de la maquette (contient, sans casse). Absent, toutes les maquettes.
          schema: { type: string, maxLength: 200 }
        - name: project_id
          in: query
          description: Maquettes de ce projet seulement ; projet inconnu ou d'une autre organisation, 404.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Une page de maquettes, avec leurs projets.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ModelSearchItem" }
              example:
                data:
                  - id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                    name: LNPCA-S1-OA-PONT-0042
                    format: IFC
                    revision_count: 3
                    latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                    created_at: "2026-03-02T10:00:00Z"
                    updated_at: "2026-10-18T08:41:00Z"
                    projects:
                      - id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                        name: LNPCA — Section 1
                        space_path: "~/SNCF"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/models/{model_id}:
    parameters:
      - $ref: "#/components/parameters/ModelId"
    get:
      operationId: getModel
      summary: Lire une maquette
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: La maquette.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
              example:
                id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                name: LNPCA-S1-OA-PONT-0042
                format: IFC
                revision_count: 3
                latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                created_at: "2026-03-02T10:00:00Z"
                updated_at: "2026-10-18T08:41:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    patch:
      operationId: updateModel
      summary: Renommer une maquette
      description: |
        Seul `name` se modifie. Une maquette synchronisée depuis Trimble Connect se renomme dans Trimble (`409`).
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelUpdate" }
            example:
              name: LNPCA_OA-MN-130_STR
      responses:
        "200":
          description: La maquette renommée.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
              example:
                id: 7d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6
                name: LNPCA_OA-MN-130_STR
                format: IFC
                revision_count: 3
                latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                created_at: "2026-03-02T10:00:00Z"
                updated_at: "2026-11-04T09:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      operationId: archiveModel
      summary: Archiver une maquette
      description: |
        Comme « Archiver » dans l'App : la maquette et ses révisions sortent des listes et se restaurent depuis la section
        « Archivés » de la bibliothèque. Ses liens aux projets sont retirés et ne reviennent pas à la restauration. Refusé
        (`409`) tant qu'une de ses révisions est en traitement. Une maquette déjà archivée répond `404`.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      responses:
        "204":
          description: Maquette archivée.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
  /api/v1/models/{model_id}/naming-check:
    parameters:
      - $ref: "#/components/parameters/ModelId"
    get:
      operationId: checkModelName
      summary: Contrôler le nom de fichier d'une maquette
      description: |
        Le nom du fichier de la dernière révision, contrôlé par la convention de nommage de la maquette (même résultat
        que `POST /api/v1/naming-conventions/{naming_convention_id}/check`). `compliant` est nul quand rien n'a été
        contrôlé : maquette sans convention (`naming_convention_id` nul), convention archivée, ou sans révision.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: Le contrôle du nom.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelNameCheck" }
              example:
                model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                naming_convention_id: 4d5e6f70-8192-4a3b-9c4d-5e6f708192a3
                revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                file_name: OA12-XYZ.ifc
                compliant: false
                problems:
                  - { code: not_in_list, part: Discipline, expected: "STR, ARC" }
                values: {}
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/models/{model_id}/revisions:
    parameters:
      - $ref: "#/components/parameters/ModelId"
    get:
      operationId: listModelRevisions
      summary: Lister les révisions d'une maquette
      description: Révisions de la plus récente à la plus ancienne.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de révisions.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Revision" }
              example:
                data:
                  - id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                    model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                    version: 3
                    file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                    size_bytes: 184467200
                    ifc_schema: IFC4X3_ADD2
                    authoring_software: Revit 2025
                    status: COMPLETE
                    processing:
                      conversion: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                      dataset: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                      scene: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                    object_count: 44213
                    created_at: "2026-10-18T08:41:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createRevision
      summary: Ajouter une révision depuis un fichier déposé
      description: Nouvelle version de la maquette. La conversion démarre aussitôt ; la réponse est le job.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RevisionCreate" }
            example:
              upload_id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
              milestone: APD
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: getRevision
      summary: Lire une révision et son état de traitement
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: La révision.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Revision" }
              example:
                id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                version: 3
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                ifc_schema: IFC4X3_ADD2
                authoring_software: Revit 2025
                status: PROCESSING
                processing:
                  conversion: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                  dataset: { status: PROCESSING, percent_complete: 42, eta_seconds: 180, error_message: null }
                  scene: { status: QUEUED, percent_complete: 0, eta_seconds: null, error_message: null }
                object_count: null
                created_at: "2026-10-18T08:41:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    patch:
      operationId: updateRevision
      summary: Changer le jalon d'une révision
      description: |
        Seul `milestone` se modifie (`null` le retire). Une révision n'a pas d'autre nom : elle se désigne par sa maquette et
        son numéro de version.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RevisionUpdate" }
            example:
              milestone: APD
      responses:
        "200":
          description: La révision modifiée.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Revision" }
              example:
                id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                version: 3
                milestone: APD
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                ifc_schema: IFC4X3_ADD2
                authoring_software: Revit 2025
                status: COMPLETE
                processing:
                  conversion: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                  dataset: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                  scene: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                object_count: 44213
                created_at: "2026-10-18T08:41:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/ids-results:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: listRevisionIdsResults
      summary: Résultats IDS d'une révision
      description: |
        Un résultat par jeu d'exigences lié à la révision, avec le détail par spécification.
        Une spécification est comptée dans le score si `applicable` est vrai. Une exigence de présence non
        satisfaite a `applicable: true` et `entity_count_applicable: 0` : elle échoue, elle n'est pas « sans objet ».
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      responses:
        "200":
          description: Les résultats.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IdsResult" }
                  reason:
                    type: [string, "null"]
                    enum: [plan_required, null]
                    description: |
                      Raison pour laquelle les contrôles IDS de la révision ne sont pas lancés. `plan_required` :
                      le plan de l'organisation n'inclut pas le contrôle IDS ; les contrôles partiront sans nouveau
                      dépôt dès que le plan l'inclura. Nul sinon (contrôles lancés ou en file).
              example:
                reason: null
                data:
                  - requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    requirement_set_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                    status: COMPLETE
                    score_percent: 87.5
                    specification_count: 8
                    specification_count_applicable: 8
                    specification_count_failed: 1
                    completed_at: "2026-10-18T09:02:11Z"
                    specifications:
                      - index: 0
                        name: Classification des ouvrages d'art
                        status: PARTIAL
                        applicable: true
                        score_percent: 91.2
                        entity_count_applicable: 1250
                        entity_count_valid: 1140
                        entity_count_invalid: 110
                      - index: 1
                        name: Présence d'un IfcSite géoréférencé
                        status: FAIL
                        applicable: true
                        score_percent: 0
                        entity_count_applicable: 0
                        entity_count_valid: 0
                        entity_count_invalid: 0
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/ids-failures:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: listRevisionIdsFailures
      summary: Échecs IDS détaillés d'une révision pour un jeu d'exigences
      description: |
        Pour chaque spécification en échec (applicable, `FAIL`, `PARTIAL` ou `MISMATCH`) : ce que l'IDS demande
        (`applicability`, `requirements`), le genre d'échec (`failure`) et un échantillon d'objets fautifs avec, exigence
        par exigence, ce que l'objet porte (`checks`). Le verdict reste celui du contrôle ; `checks` montre seulement les
        valeurs lues dans la maquette (une propriété se lit sur l'objet, sinon sur son type).

        Une source manquante est dite, jamais remplacée par un vide : `definitions_available` faux si le fichier IDS n'a
        pas pu être lu, `details_available` faux si le contrôle n'a pas écrit la liste des objets en échec (contrôle
        ancien : le relancer), `object_data_available` faux si le jeu de données de la révision n'existe plus. Tant que
        `status` n'est pas `COMPLETE`, `data` est vide.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      parameters:
        - name: requirement_set_id
          in: query
          required: true
          description: Jeu d'exigences (`requirement_set_id` d'un résultat IDS de la révision).
          schema: { type: string, format: uuid }
        - name: requirement_set_revision_id
          in: query
          description: Version du jeu ; la plus récente contrôlée sur cette révision par défaut.
          schema: { type: string, format: uuid }
        - name: specification_index
          in: query
          description: Une seule spécification (`index` du résultat), en échec ou non.
          schema: { type: integer, format: int32, minimum: 0, maximum: 10000 }
        - name: limit
          in: query
          description: Spécifications par page.
          schema: { type: integer, format: int32, minimum: 1, maximum: 50, default: 20 }
        - name: object_limit
          in: query
          description: Objets fautifs montrés par spécification ; 0 pour les facettes seules.
          schema: { type: integer, format: int32, minimum: 0, maximum: 50, default: 5 }
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Les échecs détaillés.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdsFailures" }
              example:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_set_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                status: COMPLETE
                completed_at: "2026-10-18T09:02:11Z"
                definitions_available: true
                details_available: true
                object_data_available: true
                data:
                  - index: 3
                    name: Annotation/Beam/Building
                    description: null
                    instructions: null
                    identifier: r-0e6a1d4ef924
                    status: PARTIAL
                    applicable: true
                    cardinality: optional
                    failure: INVALID_OBJECTS
                    score_percent: 41.2
                    entity_count_applicable: 120
                    entity_count_invalid: 71
                    applicability:
                      - { type: ENTITY, entity: "one of IFCBEAM | IFCBUILDING" }
                    requirements:
                      - type: PROPERTY
                        cardinality: required
                        property_set: DONNEES_SNCF_RESEAU
                        name: ID_LIGNE
                        value: "pattern L[0-9]{3}"
                        data_type: IFCTEXT
                    invalid_objects:
                      - object_id: 80740
                        global_id: 1EveRUJ9f8ABmwj$jhNe5l
                        name: Poutre P12
                        ifc_class: IfcBeam
                        checks:
                          - { requirement: 0, state: PRESENT, value: "830", value_matches: false, source: object }
                    invalid_objects_total: 71
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/ids-failures/bcf:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: exportRevisionIdsFailuresBcf
      summary: Non-conformités IDS d'une révision en fichier BCF
      description: |
        Paquet BCF 2.1 (`.bcfzip`) des échecs d'un jeu d'exigences sur la révision : un topic par spécification en
        échec, ses objets sélectionnés par GlobalId (2 000 par topic au plus, le reste est compté dans la description).
        Une exigence de présence non satisfaite donne un topic sans point de vue. Le fichier ouvre dans Solibri,
        BIMcollab, Revit ou Navisworks, et un même export réimporté met à jour ses topics au lieu de les dupliquer.

        La réponse est un petit JSON ; le fichier se télécharge par `download_url`, lien signé valable une heure.
        `topic_count` à 0 et `download_url` nul : aucune spécification en échec. `details_available` faux : le contrôle
        n'a pas écrit la liste des objets testés (contrôle ancien : le relancer). Tant que `status` n'est pas
        `COMPLETE`, aucun fichier n'est produit.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      parameters:
        - name: requirement_set_id
          in: query
          required: true
          description: Jeu d'exigences (`requirement_set_id` d'un résultat IDS de la révision).
          schema: { type: string, format: uuid }
        - name: requirement_set_revision_id
          in: query
          description: Version du jeu ; la plus récente contrôlée sur cette révision par défaut.
          schema: { type: string, format: uuid }
        - name: specification_index
          in: query
          description: Une seule spécification (`index` du résultat) ; toutes celles en échec par défaut.
          schema: { type: integer, format: int32, minimum: 0, maximum: 10000 }
      responses:
        "200":
          description: Le fichier prêt, ou la raison de son absence.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdsFailuresBcf" }
              example:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_set_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                status: COMPLETE
                completed_at: "2026-10-18T09:02:11Z"
                details_available: true
                topic_count: 12
                file_name: non-conformites-ids-LNPCA_New.bcfzip
                size_bytes: 48213
                download_url: https://storage.example.com/object/sign/attachments/cache/rev/validity/set/api-failures-all.bcfzip?token=...
                download_expires_at: "2026-10-18T10:05:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/objects:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: queryRevisionObjects
      summary: Chercher des objets d'une révision (classe, propriété, valeur)
      description: |
        Lecture ciblée du jeu de données, au plus 50 objets par page, par exemple « les IfcWall dont
        `Pset_WallCommon.FireRating` est absente ou vide ». Une propriété se lit sur l'objet, sinon sur son type (l'objet
        l'emporte). La classe se compare sans tenir compte de la casse et sans sous-classes ; sans classe, seuls les objets
        de la maquette sont lus (ni types ni définitions). Les valeurs sortent en texte. `total` n'est calculé que pour la
        première page (nul si le compte est trop long). `count_values=true` compte en plus les valeurs de la propriété ou
        de l'attribut sur toute la sélection (`value_counts`, les 50 plus fréquentes, et `distinct_value_count`), sur la
        première page seulement. Jeu de données pas terminé ou plus disponible : `409`.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      x-heavy: true
      parameters:
        - name: ifc_class
          in: query
          description: Classe IFC, par exemple `IfcWall`.
          schema: { type: string, maxLength: 200 }
        - name: property_set
          in: query
          description: Jeu de propriétés ; va avec `property`.
          schema: { type: string, maxLength: 200 }
        - name: property
          in: query
          description: Nom de la propriété ; va avec `property_set`.
          schema: { type: string, maxLength: 200 }
        - name: attribute
          in: query
          description: Attribut IFC lu à la place d'une propriété.
          schema: { type: string, enum: [Name, Description, ObjectType, Tag, LongName, PredefinedType, GlobalId] }
        - name: condition
          in: query
          description: |
            Filtre sur la propriété ou l'attribut : `any` (aucun filtre, valeur rendue), `missing` (absente), `empty`
            (présente sans valeur), `missing_or_empty`, `present` (renseignée), `equals`, `not_equals` (renseignée et
            différente), `contains` (sans tenir compte de la casse).
          schema:
            type: string
            enum: [any, missing, empty, missing_or_empty, present, equals, not_equals, contains]
            default: any
        - name: value
          in: query
          description: Valeur comparée, obligatoire pour `equals`, `not_equals` et `contains`.
          schema: { type: string, maxLength: 200 }
        - name: limit
          in: query
          description: Taille de page.
          schema: { type: integer, format: int32, minimum: 1, maximum: 50, default: 50 }
        - name: count_values
          in: query
          description: |
            Compter les valeurs sur toute la sélection, avec `property` ou `attribute`. Nul si le comptage est trop long ;
            la page d'objets reste servie. Passer `limit=1` pour ne lire que les comptes.
          schema: { type: boolean, default: false }
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page d'objets.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ObjectPage" }
              example:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                data:
                  - object_id: 470981
                    global_id: 1EveRUJ9f8ABmwj$jhNe5l
                    name: Mur M12
                    ifc_class: IfcWall
                    state: MISSING
                    value: null
                    value_source: null
                next_cursor: null
                has_more: false
                total: 1
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/ids-summary:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProjectIdsSummary
      summary: Synthèse IDS d'un projet (dernière révision de chaque maquette)
      description: Agrégé côté serveur ; une seule requête, quel que soit le nombre de maquettes.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      responses:
        "200":
          description: La synthèse.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdsSummary" }
              example:
                project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                model_count: 197
                model_count_checked: 190
                model_count_in_progress: 5
                model_count_error: 2
                score_percent: 82.4
                by_requirement_set:
                  - requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    name: Exigences BIM LNPCA v3
                    model_count_checked: 190
                    score_percent: 82.4
                computed_at: "2026-10-21T07:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/exports:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    post:
      operationId: createRevisionExport
      summary: Exporter les données d'une révision
      description: |
        Prépare une archive zip, un fichier par table (CSV par défaut, ou JSON avec `format: json`). Le jeu de
        données de la révision doit être terminé (`409` sinon). Quand le job est `COMPLETE`, son `result` donne une
        URL de téléchargement signée valable une heure, renouvelée à chaque lecture du job ; l'archive est gardée
        48 h. Une table en échec est absente de l'archive et nommée dans `error` (code `EXPORT_PARTIAL`).

        **Format des fichiers.** CSV : UTF-8 sans BOM, séparateur `|`, chaque valeur entre guillemets, fin de ligne CRLF,
        une ligne d'en-tête. Les colonnes de jointure y figurent sous leurs deux noms (`asset_id` et `AssetId`, `name` et
        `Name`…), et `RevisionId` peut figurer deux fois, comme le lisent les modèles Power BI. JSON : un tableau d'objets,
        chaque colonne une seule fois, sous son nom en PascalCase. Dans les deux formats toute valeur est du **texte**
        (nombres, booléens `true`/`false`, dates) : la convertir avant de calculer ou de trier. Une table sans ligne pour
        la révision donne un fichier vide (en-tête seul, ou `[]`).

        **Tables** (nom dans `tables` → fichier de l'archive). `AssetId` est l'identifiant de l'objet dans le fichier IFC
        (`#123`) et relie les tables entre elles ; `GlobalId` est l'identifiant IFC stable d'une révision à l'autre.

        `assets` (fichier `assets`), une ligne par objet : `AssetId`, `GlobalId`, `AssetClass`, `AssetClassName`, `Name`, `Description`, `ObjectType`, `Tag`, `LongName`, `PredefinedType`, `AssetTypeId`, `IsAsset`, `HasGeometry`, `IsSpatialAsset`, `IsGroupAsset`.

        `asset_types` (fichier `asset-types`), une ligne par type d'objet : `AssetTypeId`, `AssetTypeClass`, `AssetTypeClassName`, `AssetTypeGlobalId`, `AssetTypeName`, `AssetTypePredefinedType`.

        `properties` (fichier `properties`), une ligne par propriété d'un objet : `AssetId`, `AssetClassName`, `PropertySetName`, `Name`, `Value`, `Unit`, `Source`.

        `qto` (fichier `qto`), une ligne par quantité d'un objet : `AssetId`, `QuantitySetName`, `Name`, `Value`, `Unit`, `Formula`.

        `classification` (fichier `classification`), une ligne par classification d'un objet : `AssetId`, `ClassificationSystem`, `ClassificationIdentification`, `ClassificationValue`, `ClassificationTitle`.

        `materials` (fichier `materials`), une ligne par matériau (couche, profil ou constituant) d'un objet : `AssetId`, `MaterialName`, `MaterialCategory`, `MaterialLayerName`, `MaterialLayerThickness`, `MaterialConstituentName`, `MaterialConstituentFraction`, `Source`.

        `spatial_relation` (fichier `asset-spatial-parents`), une ligne par objet, avec son emplacement : `AssetId`, `AssetName`, `SiteAssetName`, `BuildingAssetName`, `BuildingStoreyAssetName`, `SpaceAssetName`, `FirstSpatialParentAssetName`.

        `asset_tree` (fichier `asset-relations`), une ligne par lien parent → enfant de l'arborescence : `AssetId`, `ChildAssetId`, `ParentAssetId`, `ParentAssetClassName`, `RelationClass`, `ParentAssetIsSpatial`, `ParentAssetIsGroup`.

        `asset_logical` (fichier `asset-logical-parents`), une ligne par objet, avec son système, sa zone, son groupe : `AssetId`, `AssetName`, `SystemAssetName`, `ZoneAssetName`, `GroupAssetName`.

        `asset_relations` (fichier `asset-relations-direct`), une ligne par relation IFC d'un objet : `AssetId`, `RelationId`, `RelationClass`.

        `relations` (fichier `relations`), une ligne par relation IFC : `RelationId`, `RelationClass`, `RelationClassName`, `RelationGlobalId`, `RelationName`.

        `space_boundary_relation` (fichier `space-boundary-relations`), une ligne par limite d'un espace : `AssetId`, `RelatingSpaceAssetId`, `RelatingSpaceAssetClassName`, `PhysicalOrVirtualBoundary`, `InternalOrExternalBoundary`.

        `asset_placement` (fichier `asset-placement`), une ligne par objet placé : `AssetId`, `X`, `Y`, `Z`, `Unit`, `Xm`, `Ym`, `Zm` (en mètres).

        `revisions` (fichier `revisions`), une ligne par révision : `Model Name`, `Schema`, `FileName`, `FileSize`, `NumberOfAssets`, `NumberOfSpaces`, `NumberOfProxies`, `NumberOfProperties`, `GlobalArea`, `SitePointX`/`Y`/`Z`, adresse du bâtiment.

        Chaque fichier commence par `RevisionId`. La liste complète des colonnes d'une table est l'en-tête de son fichier.
      tags: [Exports]
      security:
        - apiKey: []
      x-required-scopes: [exports:read]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExportCreate" }
            example:
              tables: [assets, properties, qto]
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/spaces:
    get:
      operationId: listSpaces
      summary: Lister les espaces
      description: |
        Espaces de l'organisation, archivés compris (`archived`). `space_id` d'un projet désigne l'un d'eux.
      tags: [Espaces]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: q
          in: query
          description: Filtre sur le nom (contient, sans casse).
          schema: { type: string, maxLength: 200 }
        - name: parent_id
          in: query
          description: Sous-espaces directs de cet espace ; espace inconnu ou d'une autre organisation, 404.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Une page d'espaces.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Space" }
              example:
                data:
                  - id: bd52f342-c3ec-43f8-a3d0-90a7a7c0c269
                    name: LNPCA_SYNTHESE
                    parent_id: null
                    path: ~/LNPCA_SYNTHESE
                    archived: false
                    project_count: 3
                    created_at: "2026-09-10T13:57:20Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/spaces/{space_id}/requirement-sets:
    parameters:
      - $ref: "#/components/parameters/SpaceId"
    post:
      operationId: moveRequirementSetToSpace
      summary: Ranger un jeu d'exigences existant dans un espace
      description: |
        Un jeu d'exigences vit à un seul niveau (organisation, espace ou projet) : cette opération le range dans l'espace,
        comme « Promouvoir » dans l'App. Le jeu n'est pas copié : ses versions, ses liens aux projets et ses résultats
        restent les mêmes. Déjà rangé dans cet espace : rendu tel quel. Un jeu de portée organisation est déjà visible
        dans tous les espaces ; le ranger dans un seul le retirerait des autres, l'opération répond donc `409`. Pour
        créer directement un jeu dans un espace, passer `space_id` à `POST /api/v1/requirement-sets`.
      tags: [IDS, Espaces]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpaceRequirementSet" }
            example:
              requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
      responses:
        "200":
          description: Le jeu, rangé dans l'espace.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RequirementSet" }
              example:
                id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                name: Exigences BIM LNPCA v3
                format: IDS
                scope: SPACE
                scope_id: bd52f342-c3ec-43f8-a3d0-90a7a7c0c269
                latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                specification_count: 8
                created_at: "2026-02-10T14:00:00Z"
                updated_at: "2026-11-12T09:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/spaces/{space_id}/dashboards:
    parameters:
      - $ref: "#/components/parameters/SpaceId"
    get:
      operationId: listSpaceDashboards
      summary: Lister les tableaux de bord d'un espace
      description: |
        Tableaux de bord rangés dans l'espace (ceux de la bibliothèque de l'espace dans l'App). Ceux de portée
        organisation, visibles dans tous les espaces, n'y sont pas répétés.
      tags: [Tableaux de bord]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de tableaux de bord.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Dashboard" }
              example:
                data:
                  - id: 7e2f9a10-4b3c-4d5e-8f60-1a2b3c4d5e6f
                    name: Contrôle IDS LNPCA
                    description: "Résultats des exigences IDS de vos maquettes : scores, règles, objets en échec."
                    format: HTML
                    space_id: bd52f342-c3ec-43f8-a3d0-90a7a7c0c269
                    template_id: 00000000-0000-0000-0000-00000000a000
                    created_at: "2026-11-12T09:00:00Z"
                    render_url: https://www.seve-up.app/fr/library/dashboards/7e2f9a10-4b3c-4d5e-8f60-1a2b3c4d5e6f/view
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createSpaceDashboard
      summary: Créer un tableau de bord dans un espace depuis un modèle
      description: |
        Copie le modèle `template_id` dans l'espace, sous le nom `name`, au nom du créateur de la clé : comme « Dupliquer
        dans ce projet » de l'App (mêmes droits, même contrôle du fichier). Le modèle est un modèle du catalogue
        (`GET /api/v1/dashboard-templates`, par exemple « Conformité BIM »
        `00000000-0000-0000-0000-00000000a000`) ou un tableau de bord de l'organisation. La copie ne suit pas les mises à
        jour ultérieures du modèle. Créer un tableau de bord propre à l'organisation exige un plan qui l'inclut
        (`403 PLAN_REQUIRED` sinon). Modèle inconnu, d'une autre organisation ou plus proposé : `404
        TEMPLATE_NOT_FOUND` ; modèle réservé non débloqué : `403 TEMPLATE_RESERVED` ; espace archivé : `409`. Deux
        tableaux de bord peuvent porter le même nom.
      tags: [Tableaux de bord]
      security:
        - apiKey: []
      x-required-scopes: [dashboards:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardCreate" }
            example:
              name: Contrôle IDS LNPCA
              template_id: 00000000-0000-0000-0000-00000000a000
      responses:
        "201":
          description: Tableau de bord créé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
              example:
                id: 7e2f9a10-4b3c-4d5e-8f60-1a2b3c4d5e6f
                name: Contrôle IDS LNPCA
                description: "Résultats des exigences IDS de vos maquettes : scores, règles, objets en échec."
                format: HTML
                space_id: bd52f342-c3ec-43f8-a3d0-90a7a7c0c269
                template_id: 00000000-0000-0000-0000-00000000a000
                created_at: "2026-11-12T09:00:00Z"
                render_url: https://www.seve-up.app/fr/library/dashboards/7e2f9a10-4b3c-4d5e-8f60-1a2b3c4d5e6f/view
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/dashboard-templates:
    get:
      operationId: listDashboardTemplates
      summary: Lister les modèles de tableaux de bord proposés
      description: |
        Modèles du catalogue SeveUp proposés à l'organisation, à passer en `template_id` de
        `POST /api/v1/spaces/{space_id}/dashboards`. `reserved` : modèle visible mais réservé, à faire débloquer par
        SeveUp avant usage.
      tags: [Tableaux de bord]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de modèles.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/DashboardTemplate" }
              example:
                data:
                  - id: 00000000-0000-0000-0000-00000000a000
                    name: Conformité BIM
                    description: "Résultats des exigences IDS de vos maquettes : scores, règles, objets en échec."
                    format: HTML
                    reserved: false
                    published_at: "2026-10-08T09:09:07Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/uploads:
    post:
      operationId: createUpload
      summary: Ouvrir un dépôt de fichier
      description: |
        Renvoie un point d'entrée TUS (reprise possible) et les en-têtes à joindre à chaque requête TUS
        (`Authorization`, `Tus-Resumable`, `Upload-Metadata`). Le jeton de dépôt est valable 2 h (`expires_at`) :
        au-delà, ouvrir un nouveau dépôt. Le fichier ne transite pas par l'API. Une fois le fichier envoyé, appeler
        `complete`. Taille maximale selon le plan : `413` au-delà. Avec `purpose: model`, la clé doit porter
        `models:write` ; avec `purpose: ids`, `ids:write`. Extensions : `.ifc` pour une maquette, `.ids` ou `.xlsx`
        pour un jeu d'exigences.
      tags: [Fichiers]
      security:
        - apiKey: []
      x-required-scopes: [models:write, ids:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UploadCreate" }
            example:
              file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
              size_bytes: 184467200
              purpose: model
      responses:
        "201":
          description: Dépôt ouvert.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
              example:
                id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
                status: PENDING
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                purpose: model
                upload_url: https://ooddxuvlammhnltcfqpm.supabase.co/storage/v1/upload/resumable
                upload_headers:
                  Authorization: Bearer eyJhbGciOi…
                  Tus-Resumable: 1.0.0
                  Upload-Metadata: bucketName YXR0YWNobWVudHM=,objectName …
                expires_at: "2026-10-21T10:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/uploads/{upload_id}/complete:
    parameters:
      - $ref: "#/components/parameters/UploadId"
    post:
      operationId: completeUpload
      summary: Clore un dépôt de fichier
      description: |
        Vérifie que le fichier est arrivé en entier (taille annoncée). L'empreinte MD5 fournie est conservée avec le
        fichier ; elle n'est pas recalculée. `409` si le fichier n'est pas encore complet ou si le dépôt a expiré.
        Rappeler `complete` sur un dépôt déjà clos renvoie le même résultat.
      tags: [Fichiers]
      security:
        - apiKey: []
      x-required-scopes: [models:write, ids:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                md5_hex:
                  type: string
                  pattern: "^[a-f0-9]{32}$"
                  description: Empreinte MD5 du fichier envoyé, conservée avec lui (non recalculée).
            example:
              md5_hex: 9e107d9d372bb6826bd81d3542a419d6
      responses:
        "200":
          description: Dépôt clos ; le fichier peut servir à une maquette ou un jeu d'exigences.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
              example:
                id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
                status: COMPLETE
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                purpose: model
                upload_url: null
                upload_headers: null
                expires_at: "2026-10-21T10:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/requirement-sets:
    get:
      operationId: listRequirementSets
      summary: Lister les jeux d'exigences IDS
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: project_id
          in: query
          description: Jeux liés à ce projet seulement ; projet inconnu ou d'une autre organisation, 404.
          schema: { type: string, format: uuid }
        - name: space_id
          in: query
          description: |
            Jeux rangés dans cet espace seulement (les jeux de portée organisation, visibles partout, n'y sont pas
            répétés) ; espace inconnu ou d'une autre organisation, 404.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Une page de jeux d'exigences.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/RequirementSet" }
              example:
                data:
                  - id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    name: Exigences BIM LNPCA v3
                    format: IDS
                    scope: PROJECT
                    scope_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                    latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                    specification_count: 8
                    created_at: "2026-02-10T14:00:00Z"
                    updated_at: "2026-09-30T11:20:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createRequirementSet
      summary: Créer un jeu d'exigences depuis un fichier IDS déposé
      description: |
        Portée du jeu : le projet `project_id` (et lié à lui), l'espace `space_id`, ou l'organisation sans l'un ni
        l'autre (le créateur de la clé doit alors être administrateur de l'organisation). `project_id` et `space_id`
        ensemble : `400`. Projet ou espace inconnu ou d'une autre organisation : `404`.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RequirementSetCreate" }
            example:
              name: Exigences BIM LNPCA v3
              upload_id: 8b2d3f4a-5c6e-4f7a-9b0c-1d2e3f4a5b6c
              project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
      responses:
        "201":
          description: Jeu créé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RequirementSet" }
              example:
                id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                name: Exigences BIM LNPCA v3
                format: IDS
                scope: PROJECT
                scope_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                specification_count: 8
                created_at: "2026-10-21T08:00:00Z"
                updated_at: "2026-10-21T08:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/requirement-sets/{requirement_set_id}:
    parameters:
      - $ref: "#/components/parameters/RequirementSetId"
    get:
      operationId: getRequirementSet
      summary: Lire un jeu d'exigences
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      responses:
        "200":
          description: Le jeu d'exigences.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RequirementSet" }
              example:
                id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                name: Exigences BIM LNPCA v3
                format: IDS
                scope: PROJECT
                scope_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                specification_count: 8
                created_at: "2026-02-10T14:00:00Z"
                updated_at: "2026-09-30T11:20:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    delete:
      operationId: deleteRequirementSet
      summary: Supprimer un jeu d'exigences
      description: |
        **Définitif**, comme la suppression dans l'App : le jeu, toutes ses versions, ses liens aux projets et maquettes et
        tous les résultats IDS obtenus avec lui sont effacés, sans restauration possible. Exige `confirm=true`. Refusé
        (`409`) tant qu'un contrôle de ce jeu est en cours.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      parameters:
        - name: confirm
          in: query
          required: true
          description: "`true` : confirme la suppression définitive."
          schema: { type: string, enum: ["true"] }
      responses:
        "204":
          description: Jeu supprimé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
  /api/v1/requirement-sets/{requirement_set_id}/specifications:
    parameters:
      - $ref: "#/components/parameters/RequirementSetId"
    get:
      operationId: listRequirementSetSpecifications
      summary: Lire ce que demande un jeu d'exigences
      description: |
        Spécifications du fichier IDS, dans l'ordre du contrôle (`index` = position des résultats IDS) : applicabilité
        (quels objets sont visés) et exigences (propriétés, attributs, classifications, matériaux attendus, avec leur
        valeur ou leur contrainte). Ne demande ni maquette ni contrôle.

        `specifications_available` faux : le fichier de cette version n'est pas un IDS lisible (`data` vide), à ne pas
        confondre avec un jeu sans spécification.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      parameters:
        - name: requirement_set_revision_id
          in: query
          description: Version du jeu (`requirement_set_revision_id` d'un résultat IDS) ; la plus récente par défaut.
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de spécifications.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RequirementSetSpecifications" }
              example:
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_set_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                version: 3
                name: Exigences BIM LNPCA v3
                format: IDS
                specifications_available: true
                specification_count: 8
                data:
                  - index: 0
                    name: Murs - résistance au feu
                    description: Chaque mur porte sa résistance au feu.
                    instructions: null
                    identifier: EX-012
                    cardinality: required
                    applicability:
                      - type: ENTITY
                        entity: IFCWALL
                    requirements:
                      - type: PROPERTY
                        cardinality: required
                        property_set: Pset_WallCommon
                        name: FireRating
                        value: "one of REI30 | REI60 | REI120"
                        data_type: IFCLABEL
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/ids-checks:
    post:
      operationId: createIdsCheck
      summary: Contrôler une révision avec un jeu d'exigences
      description: |
        Lance le contrôle (ou le relance si la paire a déjà un résultat). Si le jeu n'est rattaché ni à la maquette
        ni à un projet qui la contient, il est rattaché à la maquette, comme depuis l'application : ses autres
        révisions seront contrôlées aussi. Le jeu contrôlé est la version épinglée par le rattachement, sinon la
        dernière. Le résultat se lit ensuite dans `GET /api/v1/revisions/{revision_id}/ids-results`. Compte dans le
        quota mensuel de contrôles du plan. `400` si la révision ou le jeu est inconnu dans l'organisation, `409`
        si le jeu n'a aucune révision ou si la conversion de la révision a échoué.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdsCheckCreate" }
            example:
              revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
              requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/naming-conventions:
    get:
      operationId: listNamingConventions
      summary: Lister les conventions de nommage
      description: |
        Conventions de nommage des fichiers de l'organisation (archivées exclues), des plus anciennes aux plus récentes.
        `project_id` limite aux conventions rattachées à un projet. La liste donne le nombre de parties du nom ; leurs
        règles se lisent avec `GET /api/v1/naming-conventions/{naming_convention_id}`.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: project_id
          in: query
          description: Projet auquel la convention est rattachée.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Une page de conventions.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NamingConvention" }
              example:
                data:
                  - id: 4d5e6f70-8192-4a3b-9c4d-5e6f708192a3
                    name: LNPCA — maquettes
                    delimiter: "-"
                    part_count: 5
                    project_ids: [5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10]
                    created_at: "2026-02-10T08:00:00Z"
                    updated_at: "2026-09-14T15:30:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/naming-conventions/{naming_convention_id}:
    parameters:
      - $ref: "#/components/parameters/NamingConventionId"
    get:
      operationId: getNamingConvention
      summary: Lire une convention de nommage
      description: |
        La convention et ses parties dans l'ordre du nom : type (`text`, `number`, `version` calculée), caractère
        obligatoire, bornes d'un nombre, longueur maximale, motif (expression régulière) et valeurs permises. Pour une
        partie à liste, `allowed_values[].value` est ce qui doit figurer dans le nom.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: La convention.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NamingConvention" }
              example:
                id: 4d5e6f70-8192-4a3b-9c4d-5e6f708192a3
                name: LNPCA — maquettes
                delimiter: "-"
                part_count: 2
                project_ids: [5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10]
                created_at: "2026-02-10T08:00:00Z"
                updated_at: "2026-09-14T15:30:00Z"
                parts:
                  - position: 0
                    name: Ouvrage
                    description: Code de l'ouvrage d'art.
                    type: text
                    required: true
                    min_value: null
                    max_value: null
                    max_length: 6
                    pattern: "^OA[0-9]+$"
                    allowed_values: []
                  - position: 1
                    name: Discipline
                    description: null
                    type: text
                    required: true
                    min_value: null
                    max_value: null
                    max_length: null
                    pattern: null
                    allowed_values:
                      - { value: STR, label: Structure, group: null }
                      - { value: ARC, label: Architecture, group: null }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/naming-conventions/{naming_convention_id}/check:
    parameters:
      - $ref: "#/components/parameters/NamingConventionId"
    post:
      operationId: checkFileNames
      summary: Contrôler des noms de fichiers
      description: |
        Même contrôle que l'App à l'import d'un fichier, sans rien déposer ni écrire. L'extension est ignorée (`.ifc`,
        et la seconde d'un `.ifc.zip`). Chaque nom rend `compliant`, ses problèmes et, s'il est conforme, la valeur lue
        pour chaque partie. Codes : `no_rule` (convention sans partie), `part_count` (nombre de parties attendu dans
        `expected`), `required`, `not_a_number`, `out_of_range`, `too_long`, `not_in_list`, `pattern`. Un motif invalide ou
        trop coûteux à évaluer refuse le nom. 500 noms au plus par appel ; un nom répété n'est contrôlé qu'une fois.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FileNameCheckRequest" }
            example:
              file_names: [OA12-STR.ifc, OA12-XYZ.ifc]
      responses:
        "200":
          description: Le résultat de chaque nom, dans l'ordre reçu.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileNameCheck" }
              example:
                naming_convention_id: 4d5e6f70-8192-4a3b-9c4d-5e6f708192a3
                compliant_count: 1
                results:
                  - file_name: OA12-STR.ifc
                    compliant: true
                    problems: []
                    values: { Ouvrage: OA12, Discipline: STR }
                  - file_name: OA12-XYZ.ifc
                    compliant: false
                    problems:
                      - { code: not_in_list, part: Discipline, expected: "STR, ARC" }
                    values: {}
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/issue-boards:
    get:
      operationId: listIssueBoards
      summary: Lister les tableaux d'issues
      description: |
        Un tableau par espace racine de l'organisation (`source` `seveup`, modifiable), plus les projets Trimble Connect
        ou Catenda synchronisés (`trimble`, `catenda`), en lecture seule ici comme dans l'App : leurs issues se modifient
        dans l'outil d'origine. Chaque tableau donne les statuts, types et priorités acceptés par `PATCH
        /api/v1/issues/{issue_id}`. Liste complète en une page.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:read]
      responses:
        "200":
          description: Les tableaux d'issues.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/IssueBoard" }
              example:
                data:
                  - id: 3e7d1c55-8a2b-4f0e-9c6d-1b2a3c4d5e6f
                    name: SNCF
                    source: seveup
                    space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                    editable: true
                    statuses:
                      - { name: Ouvert, type: open }
                      - { name: En cours, type: open }
                      - { name: Résolu, type: open }
                      - { name: Fermé, type: closed }
                    types: [Clash, Question, Remarque]
                    priorities: [Basse, Normale, Haute, Critique]
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/issues:
    get:
      operationId: listIssues
      summary: Lister les issues
      description: |
        Issues non supprimées de l'organisation, des plus anciennes aux plus récentes. `board_id` limite à un tableau,
        `model_id` aux issues posées sur une maquette, `status_type` aux issues ouvertes (`open`) ou fermées (`closed`)
        quel que soit le nom du statut ; une issue « Résolu », en attente de clôture, est ouverte. La liste ne porte pas les commentaires (`comment_count` seulement) : les lire
        avec `GET /api/v1/issues/{issue_id}`.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: board_id
          in: query
          description: Tableau (`id` rendu par `GET /api/v1/issue-boards`).
          schema: { type: string, format: uuid }
        - name: model_id
          in: query
          description: Maquette sur laquelle l'issue est posée.
          schema: { type: string, format: uuid }
        - name: status_type
          in: query
          description: "`open` (statuts ouverts, en cours et résolus en attente de clôture) ou `closed`."
          schema: { type: string, enum: [open, closed] }
      responses:
        "200":
          description: Une page d'issues.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Issue" }
              example:
                data:
                  - id: 9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                    board_id: 3e7d1c55-8a2b-4f0e-9c6d-1b2a3c4d5e6f
                    ref: "#12"
                    title: Pset_WallCommon absent sur les murs du niveau R+1
                    description: Compléter IsExternal et FireRating.
                    status: Ouvert
                    status_type: open
                    type: Clash
                    priority: Haute
                    assigned_to: bet-structure@exemple.fr
                    due_date: "2026-11-20T00:00:00Z"
                    storey: R+1
                    models:
                      - model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                        revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    comment_count: 2
                    viewpoint_count: 1
                    editable: true
                    created_by: julie.martin@exemple.fr
                    created_at: "2026-11-02T10:15:00Z"
                    modified_by: julie.martin@exemple.fr
                    modified_at: "2026-11-03T08:40:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    post:
      operationId: createIssue
      summary: Créer une issue
      description: |
        Comme « Nouvelle issue » dans le viewer, sans point de vue 3D : l'issue va dans le tableau de l'espace racine de
        la maquette et pointe la révision `revision_id` (la dernière sinon). Statut ouvert du tableau ; `type` et
        `priority` prennent ceux du tableau, `Remarque` et `Normale` par défaut. Signée du créateur de la clé.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IssueCreate" }
            example:
              model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
              title: Pset_WallCommon absent sur les murs du niveau R+1
              description: Compléter IsExternal et FireRating.
              type: Clash
              priority: Haute
              assigned_to: bet-structure@exemple.fr
              due_date: "2026-11-20T00:00:00Z"
      responses:
        "201":
          description: Issue créée.
          headers:
            Location:
              description: URL de l'issue.
              schema: { type: string }
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Issue" }
              example:
                id: 9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                board_id: 3e7d1c55-8a2b-4f0e-9c6d-1b2a3c4d5e6f
                ref: "#12"
                title: Pset_WallCommon absent sur les murs du niveau R+1
                description: Compléter IsExternal et FireRating.
                status: Ouvert
                status_type: open
                type: Clash
                priority: Haute
                assigned_to: bet-structure@exemple.fr
                due_date: "2026-11-20T00:00:00Z"
                storey: null
                models:
                  - model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                    revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                comment_count: 0
                viewpoint_count: 0
                editable: true
                created_by: julie.martin@exemple.fr
                created_at: "2026-11-04T09:00:00Z"
                modified_by: julie.martin@exemple.fr
                modified_at: "2026-11-04T09:00:00Z"
                comments: []
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/issues/{issue_id}:
    parameters:
      - $ref: "#/components/parameters/IssueId"
    get:
      operationId: getIssue
      summary: Lire une issue
      description: L'issue avec ses commentaires, du plus ancien au plus récent.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:read]
      responses:
        "200":
          description: L'issue.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Issue" }
              example:
                id: 9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                board_id: 3e7d1c55-8a2b-4f0e-9c6d-1b2a3c4d5e6f
                ref: "#12"
                title: Pset_WallCommon absent sur les murs du niveau R+1
                description: Compléter IsExternal et FireRating.
                status: En cours
                status_type: open
                type: Clash
                priority: Haute
                assigned_to: bet-structure@exemple.fr
                due_date: "2026-11-20T00:00:00Z"
                storey: R+1
                models:
                  - model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                    revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                comment_count: 1
                viewpoint_count: 1
                editable: true
                created_by: julie.martin@exemple.fr
                created_at: "2026-11-02T10:15:00Z"
                modified_by: bet-structure@exemple.fr
                modified_at: "2026-11-03T08:40:00Z"
                comments:
                  - id: 2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091
                    text: Corrigé dans la prochaine révision.
                    created_by: bet-structure@exemple.fr
                    created_at: "2026-11-03T08:40:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    patch:
      operationId: updateIssue
      summary: Modifier une issue
      description: |
        Seuls les champs présents changent ; `null` vide `description`, `assigned_to` ou `due_date`. `status` et `type`
        se donnent par leur nom, parmi ceux du tableau (`GET /api/v1/issue-boards`), sans tenir compte de la casse.
        Chaque changement entre dans l'historique de l'issue, comme depuis l'App. Une issue d'un projet Trimble ou
        Catenda synchronisé répond `409`. La suppression n'est pas offerte : l'App ne sait pas restaurer une issue.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IssueUpdate" }
            example:
              status: Fermé
      responses:
        "200":
          description: L'issue modifiée, avec ses commentaires.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Issue" }
              example:
                id: 9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                board_id: 3e7d1c55-8a2b-4f0e-9c6d-1b2a3c4d5e6f
                ref: "#12"
                title: Pset_WallCommon absent sur les murs du niveau R+1
                description: Compléter IsExternal et FireRating.
                status: Fermé
                status_type: closed
                type: Clash
                priority: Haute
                assigned_to: bet-structure@exemple.fr
                due_date: "2026-11-20T00:00:00Z"
                storey: R+1
                models:
                  - model_id: 7c2e9f10-3b4a-4d5e-8f6a-1b2c3d4e5f60
                    revision_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                comment_count: 0
                viewpoint_count: 1
                editable: true
                created_by: julie.martin@exemple.fr
                created_at: "2026-11-02T10:15:00Z"
                modified_by: julie.martin@exemple.fr
                modified_at: "2026-11-04T09:00:00Z"
                comments: []
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/issues/{issue_id}/comments:
    parameters:
      - $ref: "#/components/parameters/IssueId"
    post:
      operationId: createIssueComment
      summary: Commenter une issue
      description: Commentaire signé du créateur de la clé, ajouté à la fin du fil. Issue synchronisée depuis Trimble ou Catenda `409`.
      tags: [Issues]
      security:
        - apiKey: []
      x-required-scopes: [issues:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IssueCommentCreate" }
            example:
              text: Corrigé dans la prochaine révision.
      responses:
        "201":
          description: Commentaire ajouté.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IssueComment" }
              example:
                id: 2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091
                text: Corrigé dans la prochaine révision.
                created_by: bet-structure@exemple.fr
                created_at: "2026-11-03T08:40:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/jobs/{job_id}:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      operationId: getJob
      summary: Suivre un traitement long
      description: |
        Un job de conversion a pour identifiant celui de la révision ; son statut couvre conversion, jeu de données
        et scène. Scope exigé selon le type : `models:read` (conversion), `ids:read` (contrôle IDS), `exports:read`
        (export). Codes d'`error` : `PROCESSING_FAILED`, `IDS_CHECK_FAILED`, `EXPORT_FAILED`, `EXPORT_EXPIRED`,
        `EXPORT_UNAVAILABLE`, et `EXPORT_PARTIAL` sur un export `COMPLETE` dont des tables manquent.
      tags: [Jobs]
      security:
        - apiKey: []
      x-required-scopes: [models:read, ids:read, exports:read]
      responses:
        "200":
          description: Le job.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              example:
                id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
                type: export
                status: COMPLETE
                percent_complete: 100
                eta_seconds: null
                target: { type: revision, id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b }
                result:
                  download_url: https://ooddxuvlammhnltcfqpm.supabase.co/storage/v1/object/sign/exports/dataset-4f6a1b2c.zip?token=eyJhbGciOi
                  download_expires_at: "2026-10-21T09:00:00Z"
                  size_bytes: 52428800
                error: null
                created_at: "2026-10-21T07:58:00Z"
                started_at: "2026-10-21T07:58:02Z"
                completed_at: "2026-10-21T07:59:40Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks:
    get:
      operationId: listWebhooks
      summary: Lister les abonnements webhook
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page d'abonnements.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Webhook" }
              example:
                data:
                  - id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
                    name: ERP chantier
                    url: https://erp.example.com/hooks/seveup
                    events: [revision.processing.completed, ids_check.completed]
                    active: true
                    last_delivered_at: "2026-10-20T18:00:00Z"
                    last_status: 200
                    created_at: "2026-09-01T10:00:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createWebhook
      summary: Créer un abonnement webhook
      description: Le secret de signature n'est rendu qu'ici, une seule fois.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
            example:
              name: ERP chantier
              url: https://erp.example.com/hooks/seveup
              events: [revision.processing.completed, ids_check.completed]
      responses:
        "201":
          description: Abonnement créé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: Secret Standard Webhooks (`whsec_…`), montré une seule fois.
              example:
                id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
                name: ERP chantier
                url: https://erp.example.com/hooks/seveup
                events: [revision.processing.completed, ids_check.completed]
                active: true
                last_delivered_at: null
                last_status: null
                created_at: "2026-10-21T08:00:00Z"
                secret: "whsec_<montré une seule fois>"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    delete:
      operationId: deleteWebhook
      summary: Supprimer un abonnement webhook
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      responses:
        "204":
          description: Abonnement supprimé, avec son journal ; les livraisons en attente ne partent plus.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}/deliveries:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    get:
      operationId: listWebhookDeliveries
      summary: Journal de livraison d'un abonnement
      description: Les livraisons des 30 derniers jours, les plus récentes d'abord.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de livraisons.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                data:
                  - id: 0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d
                    event: ids_check.completed
                    status: delivered
                    attempts: 1
                    response_code: 200
                    error: null
                    created_at: "2026-10-21T09:02:11Z"
                    delivered_at: "2026-10-21T09:02:12Z"
                    next_attempt_at: null
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
      - $ref: "#/components/parameters/DeliveryId"
    post:
      operationId: retryWebhookDelivery
      summary: Rejouer une livraison
      description: |
        Remet la livraison en file, avec le même `webhook-id` : un destinataire qui l'avait déjà traitée la reconnaît.
        Une livraison encore en attente est rendue telle quelle. `409` si l'abonnement est désactivé ou si le corps de
        l'événement n'est plus conservé.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Livraison remise en file.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                id: 0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d
                event: ids_check.completed
                status: pending
                attempts: 0
                response_code: 401
                error: "HTTP 401"
                created_at: "2026-10-21T09:02:11Z"
                delivered_at: null
                next_attempt_at: "2026-10-22T07:20:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

webhooks:
  revision.processing.completed:
    post:
      operationId: onRevisionProcessingCompleted
      summary: Une révision est entièrement traitée
      description: |
        Livraison au format Standard Webhooks : en-têtes `webhook-id` (stable entre les essais), `webhook-timestamp`
        (secondes Unix) et `webhook-signature` (`v1,<base64 HMAC-SHA256(clé, id.timestamp.corps)>`, la clé étant le
        secret `whsec_` décodé du base64). Les bibliothèques Standard Webhooks vérifient tout cela. Refuser un
        horodatage de plus de 5 minutes. Répondre `2xx` en moins de 10 s ; sinon nouvel essai à délai croissant (1 min,
        5 min, 30 min, 2 h), 5 essais en tout. Un `4xx` autre que `408` et `429` arrête les essais ; une redirection
        n'est jamais suivie. `job_id`, quand il est présent, est celui que `GET /api/v1/jobs/{job_id}` suit.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: revision.processing.completed
              timestamp: "2026-10-18T08:55:00Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                job_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                status: COMPLETE
      responses:
        "200":
          description: Reçu.
  revision.processing.failed:
    post:
      operationId: onRevisionProcessingFailed
      summary: Le traitement d'une révision a échoué
      description: |
        Envoyé quand une étape passe en `ERROR`, comme le montre `GET /api/v1/revisions/{revision_id}`. Une relance
        automatique qui réussit ensuite donne `revision.processing.completed`.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: revision.processing.failed
              timestamp: "2026-10-18T08:55:00Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                job_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                status: ERROR
                error_message: Fichier IFC illisible
      responses:
        "200":
          description: Reçu.
  ids_check.completed:
    post:
      operationId: onIdsCheckCompleted
      summary: Un contrôle IDS est terminé
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: ids_check.completed
              timestamp: "2026-10-18T09:02:11Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_revision_id: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a
                job_id: 5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b
                status: COMPLETE
                score_percent: 87.5
      responses:
        "200":
          description: Reçu.
  ids_check.failed:
    post:
      operationId: onIdsCheckFailed
      summary: Un contrôle IDS a échoué
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: ids_check.failed
              timestamp: "2026-10-18T09:02:11Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_revision_id: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a
                status: ERROR
                error_message: Le contrôle n'a pas produit de résultat dans le délai
      responses:
        "200":
          description: Reçu.
  export.ready:
    post:
      operationId: onExportReady
      summary: Un export est prêt à télécharger
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: export.ready
              timestamp: "2026-10-21T07:59:40Z"
              data:
                job_id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
      responses:
        "200":
          description: Reçu.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sk_…
      description: |
        Clé d'organisation. Scopes : `projects:read`, `projects:write`, `models:read`, `models:write`, `ids:read`,
        `ids:write`, `exports:read`, `issues:read`, `issues:write`, `webhooks:manage`, `dashboards:write`. Une clé
        `powerbi:read` n'ouvre pas cette API.

  parameters:
    Limit:
      name: limit
      in: query
      description: Taille de page.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: Curseur opaque rendu par la page précédente (`next_cursor`).
      schema: { type: string, maxLength: 512 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Clé d'idempotence, gardée 24 h par organisation.
      schema: { type: string, minLength: 1, maxLength: 200 }
    ProjectId:
      name: project_id
      in: path
      required: true
      description: "Identifiant du projet (`id` rendu par `GET /api/v1/projects`)."
      schema: { type: string, format: uuid }
    ModelId:
      name: model_id
      in: path
      required: true
      description: "Identifiant de la maquette (`id` rendu par la liste des maquettes du projet)."
      schema: { type: string, format: uuid }
    SpaceId:
      name: space_id
      in: path
      required: true
      description: "Identifiant de l'espace (`id` rendu par `GET /api/v1/spaces`, `space_id` d'un projet)."
      schema: { type: string, format: uuid }
    RequirementSetId:
      name: requirement_set_id
      in: path
      required: true
      description: "Identifiant du jeu d'exigences (`id` rendu par `GET /api/v1/requirement-sets`)."
      schema: { type: string, format: uuid }
    RevisionId:
      name: revision_id
      in: path
      required: true
      description: "Identifiant de la révision (`latest_revision_id` d'une maquette, ou `id` d'une révision)."
      schema: { type: string, format: uuid }
    UploadId:
      name: upload_id
      in: path
      required: true
      description: "Identifiant du dépôt rendu par `POST /api/v1/uploads`."
      schema: { type: string, format: uuid }
    NamingConventionId:
      name: naming_convention_id
      in: path
      required: true
      description: "Identifiant de la convention (`id` rendu par `GET /api/v1/naming-conventions`)."
      schema: { type: string, format: uuid }
    VisaSheetId:
      name: visa_sheet_id
      in: path
      required: true
      description: "Identifiant de la fiche (`id` rendu par `GET /api/v1/projects/{project_id}/visa-sheets`)."
      schema: { type: string, format: uuid }
    DocumentId:
      name: document_id
      in: path
      required: true
      description: "Identifiant du document (`id` rendu par `GET /api/v1/projects/{project_id}/documents`)."
      schema: { type: string, format: uuid }
    KairnialExportId:
      name: export_id
      in: path
      required: true
      description: "Identifiant de l'export (`id` rendu par `POST /api/v1/kairnial-exports`)."
      schema: { type: string, format: uuid }
    IssueId:
      name: issue_id
      in: path
      required: true
      description: "Identifiant de l'issue (`id` rendu par `GET /api/v1/issues`)."
      schema: { type: string, format: uuid }
    JobId:
      name: job_id
      in: path
      required: true
      description: "Identifiant du job, rendu par toute opération qui répond `202` (aussi dans l'en-tête `Location`)."
      schema: { type: string, format: uuid }
    WebhookId:
      name: webhook_id
      in: path
      required: true
      description: "Identifiant de l'abonnement webhook."
      schema: { type: string, format: uuid }
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      description: "Identifiant de la livraison, lu dans le journal de livraison de l'abonnement."
      schema: { type: string, format: uuid }

  headers:
    RateLimitPolicy:
      description: Politique de débit de la clé (draft-ietf-httpapi-ratelimit-headers), ex. `"default";q=600;w=60`.
      schema: { type: string }
    RateLimit:
      description: Débit restant dans la fenêtre courante, ex. `"default";r=598;t=42`.
      schema: { type: string }
    RetryAfter:
      description: Secondes à attendre avant de réessayer.
      schema: { type: integer, format: int32, minimum: 1 }
    Location:
      description: URL de la ressource créée ou du job.
      schema: { type: string, format: uri }
    IdempotentReplayed:
      description: "`true` si la réponse rejoue une requête déjà reçue avec la même Idempotency-Key."
      schema: { type: boolean }

  responses:
    JobAccepted:
      description: Traitement accepté ; suivre le job.
      headers:
        Location: { $ref: "#/components/headers/Location" }
        Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Job" }
          example:
            id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
            type: conversion
            status: QUEUED
            percent_complete: 0
            eta_seconds: null
            target: { type: revision, id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b }
            result: null
            error: null
            created_at: "2026-10-21T07:58:00Z"
            started_at: null
            completed_at: null
    ValidationFailed:
      description: Requête invalide.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/validation
            title: Bad Request
            status: 400
            code: VALIDATION
            detail: "limit : doit être compris entre 1 et 200."
            instance: /api/v1/projects
            errors:
              - field: limit
                message: doit être compris entre 1 et 200
    Unauthorized:
      description: Clé absente, inconnue, expirée ou révoquée.
      headers:
        WWW-Authenticate:
          description: "`Bearer`, ou `Bearer error=\"invalid_token\"` pour une clé refusée."
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/unauthorized
            title: Unauthorized
            status: 401
            code: UNAUTHORIZED
            detail: "Clé d'API requise : Authorization: Bearer sk_…"
    Forbidden:
      description: |
        Scope manquant (`SCOPE_MISSING`), plan sans accès à l'API ou à la fonction demandée (`PLAN_REQUIRED`), écriture
        refusée en plan TRIAL (`PLAN_READ_ONLY`), route non ouverte aux clés (`API_KEY_ROUTE_FORBIDDEN`) ou modèle de
        tableau de bord réservé (`TEMPLATE_RESERVED`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/plan-required
            title: Forbidden
            status: 403
            code: PLAN_REQUIRED
            detail: L'API est incluse dans les plans PRO, BUSINESS, ENTREPRISE et CUSTOM.
    NotFound:
      description: |
        Ressource inexistante ou appartenant à une autre organisation (jamais distingué) : `NOT_FOUND`, ou
        `TEMPLATE_NOT_FOUND` pour le modèle d'un tableau de bord à créer.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/not-found
            title: Not Found
            status: 404
            code: NOT_FOUND
            detail: Ressource introuvable.
    Conflict:
      description: Idempotency-Key déjà utilisée avec un autre corps, ou état incompatible.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/conflict-idempotency
            title: Conflict
            status: 409
            code: IDEMPOTENCY_KEY_REUSED
            detail: Cette Idempotency-Key a déjà servi pour une requête différente.
    PayloadTooLarge:
      description: Fichier plus gros que la limite du plan.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/payload-too-large
            title: Payload Too Large
            status: 413
            code: FILE_TOO_LARGE
            detail: "Taille maximale pour ce plan : 3 Gio."
    TooManyRequests:
      description: Débit de la clé dépassé (`RATE_LIMITED`) ou quota mensuel du plan atteint (`QUOTA_EXCEEDED`).
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/rate-limited
            title: Too Many Requests
            status: 429
            code: RATE_LIMITED
            detail: 600 requêtes par minute au plus pour cette clé ; réessayer dans 12 s.
    Gone:
      description: Opération retirée à sa date `x-sunset`, annoncée au moins 6 mois avant (en-têtes `Deprecation` et `Sunset`).
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/gone
            title: Gone
            status: 410
            code: GONE
            detail: Opération retirée le 2027-05-01, comme annoncé dans le journal des changements.
    InternalError:
      description: Erreur inattendue du serveur, déjà journalisée de notre côté ; réessayer plus tard.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/internal-error
            title: Internal Server Error
            status: 500
            code: INTERNAL_ERROR
            detail: Erreur interne ; réessayer plus tard.
    ServiceUnavailable:
      description: Capacité de traitement saturée pour l'instant ; réessayer après `Retry-After`.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/busy
            title: Service Unavailable
            status: 503
            code: BUSY
            detail: Trop de traitements en cours pour cette organisation ; réessayer dans 30 s.

  schemas:
    Problem:
      type: object
      description: Erreur RFC 9457.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer, format: int32, minimum: 400, maximum: 599 }
        code:
          type: string
          description: |
            Code stable, en majuscules. Liste ouverte : un nouveau code peut apparaître dans la v1 ; traiter un code
            inconnu d'après le statut HTTP.
          x-extensible-enum:
            - VALIDATION
            - UNAUTHORIZED
            - INVALID_API_KEY
            - SCOPE_MISSING
            - API_KEY_ROUTE_FORBIDDEN
            - PLAN_REQUIRED
            - PLAN_READ_ONLY
            - NOT_FOUND
            - IDEMPOTENCY_KEY_REUSED
            - CONFLICT
            - FILE_TOO_LARGE
            - RATE_LIMITED
            - QUOTA_EXCEEDED
            - BUSY
            - INTERNAL_ERROR
            - GONE
            - TEMPLATE_NOT_FOUND
            - TEMPLATE_RESERVED
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          description: Détail par champ, pour `VALIDATION`.
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string }
              message: { type: string }

    Page:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items: {}
        next_cursor:
          type: [string, "null"]
          description: À passer en `cursor` pour la page suivante ; nul en fin de liste.
        has_more: { type: boolean }

    ProcessingStatus:
      type: string
      description: |
        `STANDBY` en attente d'un déclencheur, `READY_FOR_PROCESSING` prêt, `QUEUED` en file, `PROCESSING` en cours,
        `COMPLETE` terminé, `ERROR` en échec.
      enum: [STANDBY, READY_FOR_PROCESSING, QUEUED, PROCESSING, COMPLETE, ERROR]

    Organization:
      type: object
      description: Organisation (tenant) à laquelle une clé est rattachée.
      required: [id, name]
      properties:
        id: { type: string, format: uuid }
        name:
          type: [string, "null"]
          description: Nom affiché de l'organisation ; `null` s'il n'a jamais été renseigné.

    Project:
      type: object
      required: [id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        space_id:
          type: [string, "null"]
          format: uuid
          description: Espace (regroupement de projets) qui contient le projet.
        space_name:
          type: [string, "null"]
          description: Nom de l'espace ; distingue deux projets de même nom rangés dans des espaces différents.
        space_path:
          type: [string, "null"]
          description: Chemin de l'espace dans l'arborescence de l'organisation, par exemple `~/SNCF`.
        model_count: { type: integer, format: int32, minimum: 0 }
        requirement_set_count: { type: integer, format: int32, minimum: 0 }
        start_date: { type: [string, "null"], format: date-time }
        end_date: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ModelUpdate:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }

    RevisionUpdate:
      type: object
      required: [milestone]
      additionalProperties: false
      properties:
        milestone: { type: [string, "null"], maxLength: 100, description: "Jalon (APS, APD, PRO, EXE…) ; `null` le retire." }

    ProjectCreate:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        description: { type: [string, "null"], maxLength: 4000 }
        space_id: { type: [string, "null"], format: uuid, description: "Espace de l'organisation (`space_id` d'un projet) ; racine si absent." }
        start_date: { type: [string, "null"], format: date-time }
        end_date: { type: [string, "null"], format: date-time, description: Postérieure ou égale à `start_date`. }

    ProjectUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        description: { type: [string, "null"], maxLength: 4000 }
        space_id: { type: [string, "null"], format: uuid, description: "`null` range le projet à la racine de l'organisation." }
        start_date: { type: [string, "null"], format: date-time }
        end_date: { type: [string, "null"], format: date-time }

    Model:
      type: object
      required: [id, name, format, revision_count, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        format: { type: string, enum: [IFC, REVIT] }
        revision_count: { type: integer, format: int32, minimum: 0 }
        latest_revision_id: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ModelProject:
      type: object
      required: [id, name, space_path]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        space_path:
          type: [string, "null"]
          description: Chemin de l'espace du projet, par exemple `~/SNCF` ; distingue deux projets de même nom.

    ModelSearchItem:
      description: Maquette trouvée par la recherche d'organisation, avec les projets qui la contiennent.
      allOf:
        - $ref: "#/components/schemas/Model"
        - type: object
          required: [projects]
          properties:
            projects:
              type: array
              description: Projets de l'organisation qui contiennent la maquette, triés par nom ; vide si aucun.
              items: { $ref: "#/components/schemas/ModelProject" }

    ModelCreate:
      type: object
      required: [name, upload_id]
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        upload_id:
          type: string
          format: uuid
          description: "Dépôt clos (`purpose: model`)."

    StepStatus:
      type: object
      required: [status]
      properties:
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        percent_complete: { type: [number, "null"], minimum: 0, maximum: 100 }
        eta_seconds: { type: [number, "null"], minimum: 0 }
        error_message: { type: [string, "null"] }

    Revision:
      type: object
      required: [id, model_id, version, status, processing, created_at]
      properties:
        id: { type: string, format: uuid }
        model_id: { type: string, format: uuid }
        version: { type: integer, format: int32, minimum: 1 }
        milestone: { type: [string, "null"], description: "Jalon (APS, APD, PRO, EXE…)." }
        file_name: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64, minimum: 0 }
        ifc_schema: { type: [string, "null"], description: "Ex. IFC2X3, IFC4, IFC4X3_ADD2." }
        authoring_software: { type: [string, "null"] }
        status:
          $ref: "#/components/schemas/ProcessingStatus"
          description: État global, le moins avancé des étapes.
        processing:
          type: object
          required: [conversion, dataset, scene]
          properties:
            conversion: { $ref: "#/components/schemas/StepStatus" }
            dataset: { $ref: "#/components/schemas/StepStatus" }
            scene: { $ref: "#/components/schemas/StepStatus" }
        object_count:
          type: [integer, "null"]
          format: int32
          minimum: 0
          description: Nombre d'objets du jeu de données ; nul tant que `dataset` n'est pas `COMPLETE`, et nul quand il est inconnu (jamais 0 par défaut).
        created_at: { type: string, format: date-time }

    RevisionCreate:
      type: object
      required: [upload_id]
      properties:
        upload_id: { type: string, format: uuid }
        milestone: { type: string, maxLength: 100 }

    IdsSpecificationResult:
      type: object
      required: [index, name, status, applicable]
      properties:
        index: { type: integer, format: int32, minimum: 0 }
        name: { type: string }
        description:
          type: [string, "null"]
          description: Description de la spécification dans le fichier IDS, quand l'IDS en a une.
        status:
          type: string
          description: "`NA` : sans objet pour cette maquette, non compté dans le score."
          enum: [PASS, PARTIAL, FAIL, NA, MISMATCH]
        applicable:
          type: boolean
          description: Compté dans le score. Seul critère d'applicabilité, jamais `entity_count_applicable > 0`.
        score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        entity_count_applicable: { type: integer, format: int32, minimum: 0 }
        entity_count_valid: { type: integer, format: int32, minimum: 0 }
        entity_count_invalid: { type: integer, format: int32, minimum: 0 }

    IdsResult:
      type: object
      required: [requirement_set_id, requirement_set_revision_id, status]
      properties:
        requirement_set_id: { type: string, format: uuid }
        requirement_set_revision_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
        specification_count_applicable: { type: [integer, "null"], format: int32, minimum: 0 }
        specification_count_failed: { type: [integer, "null"], format: int32, minimum: 0 }
        completed_at: { type: [string, "null"], format: date-time, description: "Fin du contrôle ; nul tant qu'il n'est pas terminé, et pour un contrôle ancien dont la date n'a pas été enregistrée (son résultat reste valable)." }
        specifications:
          type: array
          description: Vide tant que `status` n'est pas `COMPLETE`.
          items: { $ref: "#/components/schemas/IdsSpecificationResult" }

    IdsFacet:
      type: object
      required: [type]
      description: Facette IDS ; seuls les champs que la facette porte sont rendus.
      properties:
        type: { type: string, enum: [ENTITY, ATTRIBUTE, PROPERTY, CLASSIFICATION, MATERIAL, PART_OF] }
        cardinality:
          type: string
          enum: [required, optional, prohibited]
          description: Exigences seulement.
        entity: { type: string, description: "Classe IFC (ENTITY, PART_OF) ; une liste est rendue « one of A | B »." }
        predefined_type: { type: string }
        property_set: { type: string }
        name: { type: string, description: Nom de l'attribut ou de la propriété. }
        value: { type: string, description: "Valeur attendue : simple, ou la restriction décrite (« pattern L[0-9]{3} », « >= 0 »)." }
        data_type: { type: string }
        system: { type: string, description: Système de classification. }
        relation: { type: string }
        instructions: { type: string }

    IdsObjectCheck:
      type: object
      required: [requirement, state]
      properties:
        requirement: { type: integer, format: int32, minimum: 0, description: "Position dans `requirements`." }
        state:
          type: string
          description: |
            `MISSING` absente de l'objet et de son type, `EMPTY` présente sans valeur, `PRESENT` renseignée (voir `value`),
            `NOT_CHECKED` facette que les tables ne permettent pas de lire (classification, matériau, motif de nom…).
          enum: [MISSING, EMPTY, PRESENT, NOT_CHECKED]
        value: { type: [string, "null"] }
        value_matches:
          type: [boolean, "null"]
          description: La valeur respecte la contrainte de la facette ; nul si la facette n'en pose pas ou si la réponse n'est pas sûre.
        source: { type: [string, "null"], enum: [object, type, null] }

    IdsInvalidObject:
      type: object
      required: [object_id, checks]
      properties:
        object_id: { type: integer, format: int64, description: Identifiant de l'objet dans le fichier IFC (#123). }
        global_id: { type: [string, "null"] }
        name: { type: [string, "null"] }
        ifc_class: { type: [string, "null"] }
        checks:
          type: array
          items: { $ref: "#/components/schemas/IdsObjectCheck" }

    IdsSpecificationFailure:
      type: object
      required: [index, name, status, applicable, failure, applicability, requirements, invalid_objects]
      properties:
        index: { type: integer, format: int32, minimum: 0 }
        name: { type: string }
        description: { type: [string, "null"] }
        instructions: { type: [string, "null"] }
        identifier: { type: [string, "null"] }
        status: { type: string, enum: [PASS, PARTIAL, FAIL, NA, MISMATCH] }
        applicable: { type: boolean }
        cardinality:
          type: [string, "null"]
          enum: [required, optional, prohibited, null]
          description: "`required` : la maquette doit contenir de tels objets ; `prohibited` : elle ne doit pas en contenir."
        failure:
          type: string
          description: |
            `MISSING_OBJECTS` aucun objet visé alors que l'IDS en exige, `PROHIBITED_OBJECTS` des objets interdits sont
            présents, `APPLICABILITY_MISMATCH` la classe existe mais aucun objet ne répond à l'applicabilité,
            `INVALID_OBJECTS` des objets visés ne respectent pas les exigences, `NONE` la spécification passe,
            `NOT_APPLICABLE` sans objet.
          enum: [MISSING_OBJECTS, PROHIBITED_OBJECTS, APPLICABILITY_MISMATCH, INVALID_OBJECTS, NONE, NOT_APPLICABLE]
        score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        entity_count_applicable: { type: integer, format: int32, minimum: 0 }
        entity_count_invalid: { type: integer, format: int32, minimum: 0 }
        applicability:
          type: array
          items: { $ref: "#/components/schemas/IdsFacet" }
        requirements:
          type: array
          items: { $ref: "#/components/schemas/IdsFacet" }
        invalid_objects:
          type: array
          items: { $ref: "#/components/schemas/IdsInvalidObject" }
        invalid_objects_total:
          type: [integer, "null"]
          format: int64
          minimum: 0
          description: Objets en échec listés par le contrôle ; nul si la liste n'est pas disponible.

    IdsFailures:
      type: object
      required: [revision_id, requirement_set_id, requirement_set_revision_id, status, data, has_more]
      properties:
        revision_id: { type: string, format: uuid }
        requirement_set_id: { type: string, format: uuid }
        requirement_set_revision_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        completed_at: { type: [string, "null"], format: date-time, description: "Fin du contrôle ; nul tant qu'il n'est pas terminé, et pour un contrôle ancien dont la date n'a pas été enregistrée (son résultat reste valable)." }
        definitions_available: { type: boolean }
        details_available: { type: boolean }
        object_data_available: { type: boolean }
        data:
          type: array
          items: { $ref: "#/components/schemas/IdsSpecificationFailure" }
        next_cursor: { type: [string, "null"] }
        has_more: { type: boolean }

    IdsFailuresBcf:
      type: object
      required: [revision_id, requirement_set_id, requirement_set_revision_id, status, details_available, topic_count]
      properties:
        revision_id: { type: string, format: uuid }
        requirement_set_id: { type: string, format: uuid }
        requirement_set_revision_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        completed_at: { type: [string, "null"], format: date-time, description: "Fin du contrôle ; nul tant qu'il n'est pas terminé, et pour un contrôle ancien dont la date n'a pas été enregistrée (son résultat reste valable)." }
        details_available: { type: boolean }
        topic_count: { type: integer, format: int32, minimum: 0 }
        file_name: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64, minimum: 0 }
        download_url: { type: [string, "null"], format: uri }
        download_expires_at: { type: [string, "null"], format: date-time }

    ObjectRow:
      type: object
      required: [object_id]
      properties:
        object_id: { type: integer, format: int64 }
        global_id: { type: [string, "null"] }
        name: { type: [string, "null"] }
        ifc_class: { type: [string, "null"] }
        state:
          type: [string, "null"]
          enum: [MISSING, EMPTY, PRESENT, null]
          description: Nul sans propriété ni attribut demandés.
        value: { type: [string, "null"] }
        value_source: { type: [string, "null"], enum: [object, type, null] }

    ObjectPage:
      type: object
      required: [revision_id, data, next_cursor, has_more]
      properties:
        revision_id: { type: string, format: uuid }
        data:
          type: array
          items: { $ref: "#/components/schemas/ObjectRow" }
        next_cursor: { type: [string, "null"] }
        has_more: { type: boolean }
        total: { type: [integer, "null"], format: int64, minimum: 0 }
        value_counts:
          type: array
          description: Présent avec `count_values=true` sur la première page ; les valeurs les plus fréquentes d'abord.
          items:
            type: object
            required: [state, value, count]
            properties:
              state: { type: string, enum: [MISSING, EMPTY, PRESENT] }
              value: { type: [string, "null"], description: Valeur en texte (200 caractères au plus) ; nulle pour MISSING et EMPTY. }
              count: { type: integer, format: int64, minimum: 0 }
        distinct_value_count:
          type: integer
          format: int64
          minimum: 0
          description: Nombre de lignes de `value_counts` avant la coupe à 50.

    IdsSummary:
      type: object
      required: [project_id, model_count, computed_at]
      properties:
        project_id: { type: string, format: uuid }
        model_count: { type: integer, format: int32, minimum: 0 }
        model_count_checked: { type: integer, format: int32, minimum: 0 }
        model_count_in_progress: { type: integer, format: int32, minimum: 0 }
        model_count_error: { type: integer, format: int32, minimum: 0 }
        score_percent:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: Moyenne des maquettes contrôlées ; nul si aucune.
        by_requirement_set:
          type: array
          items:
            type: object
            required: [requirement_set_id, name]
            properties:
              requirement_set_id: { type: string, format: uuid }
              name: { type: string }
              model_count_checked: { type: integer, format: int32, minimum: 0 }
              score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        computed_at: { type: string, format: date-time }

    RequirementSet:
      type: object
      required: [id, name, format, scope, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        format: { type: string, enum: [IDS, SEVEUP] }
        scope:
          type: string
          description: Portée du jeu (organisation, espace ou projet).
          enum: [ORGANIZATION, SPACE, PROJECT]
        scope_id: { type: [string, "null"], format: uuid }
        latest_revision_id: { type: [string, "null"], format: uuid }
        specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RequirementSetSpecification:
      type: object
      required: [index, name, applicability, requirements]
      properties:
        index: { type: integer, format: int32, minimum: 0, description: Position dans le fichier, celle des résultats IDS. }
        name: { type: string }
        description: { type: [string, "null"] }
        instructions: { type: [string, "null"] }
        identifier: { type: [string, "null"] }
        cardinality:
          type: [string, "null"]
          enum: [required, optional, prohibited, null]
          description: "`required` : la maquette doit contenir de tels objets ; `prohibited` : elle ne doit pas en contenir."
        applicability:
          type: array
          items: { $ref: "#/components/schemas/IdsFacet" }
        requirements:
          type: array
          items: { $ref: "#/components/schemas/IdsFacet" }

    RequirementSetSpecifications:
      type: object
      required: [requirement_set_id, name, format, specifications_available, data, has_more]
      properties:
        requirement_set_id: { type: string, format: uuid }
        requirement_set_revision_id: { type: [string, "null"], format: uuid, description: Nul si le jeu n'a aucune version. }
        version: { type: [integer, "null"], format: int64, minimum: 0 }
        name: { type: string }
        format: { type: string, enum: [IDS, SEVEUP] }
        specifications_available:
          type: boolean
          description: Le fichier de cette version a été lu comme un IDS.
        specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
        data:
          type: array
          items: { $ref: "#/components/schemas/RequirementSetSpecification" }
        next_cursor: { type: [string, "null"] }
        has_more: { type: boolean }

    RequirementSetCreate:
      type: object
      required: [name, upload_id]
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        upload_id: { type: string, format: uuid, description: "Dépôt clos (`purpose: ids`), fichier .ids ou .xlsx." }
        project_id: { type: string, format: uuid, description: Lie le jeu à ce projet ; sinon portée organisation. }
        space_id:
          type: string
          format: uuid
          description: Range le jeu dans cet espace (portée espace) ; exclusif de `project_id`.

    Space:
      type: object
      required: [id, name, parent_id, path, archived, project_count, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        parent_id: { type: [string, "null"], format: uuid, description: Espace parent ; nul à la racine. }
        path:
          type: [string, "null"]
          description: Chemin dans l'arborescence de l'organisation, par exemple `~/SNCF/LNPCA_SYNTHESE`.
        archived: { type: boolean, description: Espace archivé dans l'App ; on n'y crée plus rien. }
        project_count: { type: integer, format: int32, minimum: 0, description: Projets non archivés rangés dans l'espace. }
        created_at:
          type: [string, "null"]
          format: date-time
          description: Nul pour les espaces créés avant que l'App ne date les espaces.

    DashboardTemplate:
      type: object
      required: [id, name, format, reserved]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        format: { type: string, enum: [HTML, POWER_BI] }
        reserved: { type: boolean, description: Réservé, à faire débloquer par SeveUp avant de pouvoir le copier. }
        published_at: { type: [string, "null"], format: date-time }

    Dashboard:
      type: object
      required: [id, name, format, created_at, render_url]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        format: { type: string, enum: [HTML, POWER_BI] }
        space_id: { type: [string, "null"], format: uuid }
        template_id:
          type: [string, "null"]
          format: uuid
          description: Modèle d'où le tableau de bord a été copié ; nul s'il a été déposé directement.
        created_at: { type: string, format: date-time }
        render_url:
          type: string
          format: uri
          description: Page de l'App qui affiche le tableau de bord, dans la langue du compte qui a créé la clé (session de l'App requise, jamais la clé d'API).

    DashboardCreate:
      type: object
      required: [name, template_id]
      properties:
        name: { type: string, minLength: 1, maxLength: 255 }
        template_id:
          type: string
          format: uuid
          description: Modèle du catalogue (`GET /api/v1/dashboard-templates`) ou tableau de bord de l'organisation.

    SpaceRequirementSet:
      type: object
      required: [requirement_set_id]
      properties:
        requirement_set_id: { type: string, format: uuid }

    IdsCheckCreate:
      type: object
      required: [revision_id, requirement_set_id]
      properties:
        revision_id: { type: string, format: uuid }
        requirement_set_id: { type: string, format: uuid }

    ExportCreate:
      type: object
      properties:
        tables:
          type: array
          description: Tables à inclure ; toutes par défaut.
          uniqueItems: true
          items:
            type: string
            enum:
              - assets
              - asset_types
              - classification
              - revisions
              - relations
              - asset_relations
              - spatial_relation
              - asset_tree
              - asset_logical
              - space_boundary_relation
              - properties
              - qto
              - materials
              - asset_placement
        format:
          type: string
          description: Format des fichiers de l'archive.
          enum: [csv, json]
          default: csv

    UploadCreate:
      type: object
      required: [file_name, size_bytes, purpose]
      properties:
        file_name: { type: string, minLength: 1, maxLength: 256 }
        size_bytes:
          type: integer
          minimum: 1
          description: Taille du fichier en octets. Au-delà de la limite du plan (3 Gio au plus), réponse `413`.
        purpose:
          type: string
          description: "`model` pour une maquette IFC, `ids` pour un jeu d'exigences."
          enum: [model, ids]

    Upload:
      type: object
      required: [id, status, file_name, size_bytes, purpose, expires_at]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [PENDING, COMPLETE] }
        file_name: { type: string }
        size_bytes: { type: integer, format: int64, minimum: 1 }
        purpose: { type: string, enum: [model, ids] }
        upload_url:
          type: [string, "null"]
          format: uri
          description: Point d'entrée TUS ; nul une fois le dépôt clos.
        upload_headers:
          type: [object, "null"]
          description: En-têtes à joindre aux requêtes TUS.
          additionalProperties: { type: string }
        expires_at: { type: string, format: date-time }

    Job:
      type: object
      required: [id, type, status, target, created_at]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [conversion, ids_check, export] }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        percent_complete:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: Nul quand le moteur n'a pas encore donné de progression.
        eta_seconds: { type: [number, "null"], minimum: 0 }
        target:
          type: object
          required: [type, id]
          properties:
            type: { type: string, enum: [revision, ids_check] }
            id: { type: string, format: uuid }
        result:
          type: [object, "null"]
          description: |
            Export : URL signée et taille. Contrôle IDS terminé : le score en une ligne (le détail se lit dans les résultats
            IDS de la révision). Conversion : nul, lire la révision.
          properties:
            download_url: { type: string, format: uri }
            download_expires_at: { type: string, format: date-time }
            size_bytes: { type: integer, format: int64, minimum: 0 }
            requirement_set_revision_id: { type: string, format: uuid }
            score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
            specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
            specification_count_applicable: { type: [integer, "null"], format: int32, minimum: 0 }
            specification_count_failed: { type: [integer, "null"], format: int32, minimum: 0 }
        error:
          type: [object, "null"]
          properties:
            code: { type: string }
            message: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    NamingConvention:
      type: object
      required: [id, name, delimiter, part_count, project_ids]
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        delimiter:
          type: string
          description: Séparateurs des parties du nom ; chaque caractère en est un (`_` par défaut).
        part_count: { type: integer, format: int32, minimum: 0 }
        project_ids:
          type: array
          description: Projets auxquels la convention est rattachée.
          items: { type: string, format: uuid }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
        parts:
          type: array
          description: Présent à la lecture d'une convention, absent des listes.
          items: { $ref: "#/components/schemas/NamingConventionPart" }

    NamingConventionPart:
      type: object
      required: [position, name, type, required, allowed_values]
      properties:
        position: { type: integer, format: int32, minimum: 0 }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        type: { type: [string, "null"], enum: [text, number, version, null] }
        required: { type: boolean }
        min_value: { type: [integer, "null"], format: int64 }
        max_value: { type: [integer, "null"], format: int64 }
        max_length: { type: [integer, "null"], format: int32 }
        pattern: { type: [string, "null"], description: Expression régulière que la partie doit vérifier en entier. }
        allowed_values:
          type: array
          description: Valeurs permises ; vide quand la partie est libre.
          items:
            type: object
            required: [value]
            properties:
              value: { type: string, description: Ce qui doit figurer dans le nom. }
              label: { type: [string, "null"] }
              group: { type: [string, "null"] }

    NamingProblem:
      type: object
      required: [code]
      properties:
        code: { type: string, enum: [no_rule, part_count, required, not_a_number, out_of_range, too_long, not_in_list, pattern] }
        part: { type: [string, "null"], description: Nom de la partie fautive. }
        expected: { type: [string, "null"], description: "Ce que la règle attend, tel quel : nombre de parties, plage, liste, motif." }

    FileNameCheckRequest:
      type: object
      required: [file_names]
      additionalProperties: false
      properties:
        file_names:
          type: array
          minItems: 1
          maxItems: 500
          items: { type: string, minLength: 1, maxLength: 512 }

    FileNameResult:
      type: object
      required: [file_name, compliant, problems, values]
      properties:
        file_name: { type: string }
        compliant: { type: boolean }
        problems: { type: array, items: { $ref: "#/components/schemas/NamingProblem" } }
        values:
          type: object
          description: Valeur lue pour chaque partie, par nom de partie ; vide si le nom n'est pas conforme.
          additionalProperties: { type: string }

    FileNameCheck:
      type: object
      required: [naming_convention_id, compliant_count, results]
      properties:
        naming_convention_id: { type: string, format: uuid }
        compliant_count: { type: integer, format: int32, minimum: 0 }
        results: { type: array, items: { $ref: "#/components/schemas/FileNameResult" } }

    ModelNameCheck:
      type: object
      required: [model_id, problems, values]
      properties:
        model_id: { type: string, format: uuid }
        naming_convention_id: { type: [string, "null"], format: uuid }
        revision_id: { type: [string, "null"], format: uuid }
        file_name: { type: [string, "null"] }
        compliant: { type: [boolean, "null"], description: Nul quand rien n'a été contrôlé. }
        problems: { type: array, items: { $ref: "#/components/schemas/NamingProblem" } }
        values: { type: object, additionalProperties: { type: string } }

    VisaGrid:
      type: object
      required: [project_id, customized, criteria, pieces]
      properties:
        project_id: { type: string, format: uuid }
        customized: { type: boolean, description: Faux tant que le projet garde la grille par défaut. }
        criteria:
          type: array
          items:
            type: object
            required: [id, section, label]
            properties:
              id: { type: string }
              section: { type: string, description: "`DIFFUSION`, `STRUCTURATION`, `REFERENCE`, `MODELISATION` ou `DATA`." }
              label: { type: string }
              scope: { type: [string, "null"], description: "`ALL`, `IFC` ou `NATIVE`." }
              origin: { type: [string, "null"], description: "`AUTO`, `IMPORT` ou `MANUAL`." }
              auto_check: { type: [string, "null"], description: Contrôle automatique qui propose un statut. }
              weight: { type: [integer, "null"], format: int32, minimum: 1, maximum: 3 }
              phases:
                type: array
                description: Phases où le critère s'applique ; vide pour toutes.
                items: { type: string }
        pieces:
          type: array
          items:
            type: object
            required: [id, label]
            properties:
              id: { type: string }
              label: { type: string }
              pattern: { type: [string, "null"], description: "Motif du nom de fichier (`*` joker) ; nul pour la maquette IFC." }
        file_name_pattern: { type: [string, "null"] }
        max_file_size_mb: { type: [number, "null"] }
        updated_at: { type: [string, "null"], format: date-time }
        updated_by: { type: [string, "null"] }

    DocumentVersion:
      type: object
      required: [id, version]
      properties:
        id: { type: string, format: uuid }
        version: { type: integer, format: int32, minimum: 1 }
        file_name: { type: [string, "null"], description: Nom du fichier déposé. }
        content_type: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64 }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
    Document:
      type: object
      required: [id, level, version_count]
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        type: { type: [string, "null"], description: "Type du document dans l'App, par exemple `DATA` ou `OTHER`." }
        level:
          type: string
          enum: [organization, space, project]
          description: Niveau où vit le document, qui décide d'où il est visible.
        space_id: { type: [string, "null"], format: uuid, description: "Espace du document quand `level` vaut `space`." }
        project_id: { type: [string, "null"], format: uuid, description: "Projet du document quand `level` vaut `project`." }
        version: { type: [integer, "null"], format: int32, description: Numéro de la version la plus récente ; nul sans version. }
        version_count: { type: integer, format: int32, minimum: 0 }
        file_name: { type: [string, "null"], description: Fichier de la version la plus récente. }
        content_type: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64 }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        modified_by: { type: [string, "null"] }
        modified_at: { type: [string, "null"], format: date-time }
        versions:
          type: array
          description: Versions, de la plus récente à la plus ancienne ; présent à la lecture d'un document.
          items: { $ref: "#/components/schemas/DocumentVersion" }
    DocumentDownload:
      type: object
      required: [document_id, version, download_url, download_expires_at]
      properties:
        document_id: { type: string, format: uuid }
        version: { type: integer, format: int32, minimum: 1 }
        file_name: { type: [string, "null"] }
        content_type: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64 }
        download_url: { type: string, format: uri, description: Lien signé, à ouvrir sans clé d'API. }
        download_expires_at: { type: string, format: date-time }
    KairnialProject:
      type: object
      required: [project_key, file_count, size_bytes]
      properties:
        project_key: { type: string, description: Clé du projet dans Kairnial. }
        name: { type: [string, "null"] }
        file_count: { type: integer, format: int64, minimum: 0, description: Fichiers en dernière version. }
        size_bytes: { type: integer, format: int64, minimum: 0 }
    KairnialExportCreate:
      type: object
      additionalProperties: false
      required: [project_key, extensions]
      properties:
        project_key: { type: string, minLength: 1, maxLength: 128, description: "`project_key` rendu par `GET /api/v1/kairnial-projects`." }
        extensions:
          type: array
          minItems: 1
          maxItems: 20
          description: Extensions retenues, sans le point et sans tenir compte de la casse.
          items: { type: string, pattern: "^[.]?[A-Za-z0-9]{1,16}$", maxLength: 17 }
        folder_id: { type: [string, "null"], minLength: 1, maxLength: 128, description: Dossier Kairnial ; ses sous-dossiers sont compris. Tout le projet par défaut. }
    KairnialExport:
      type: object
      required: [id, project_key, extensions, status, file_count, done_count, total_bytes, parts, skipped, created_at]
      properties:
        id: { type: string, format: uuid }
        project_key: { type: string }
        project_name: { type: [string, "null"] }
        extensions: { type: array, items: { type: string } }
        folder_id: { type: [string, "null"] }
        status: { type: string, enum: [QUEUED, PROCESSING, COMPLETE, ERROR, EXPIRED] }
        file_count: { type: integer, format: int32, minimum: 0, description: Fichiers retenus au lancement. }
        done_count: { type: integer, format: int32, minimum: 0, description: Fichiers traités, écartés compris. }
        total_bytes: { type: integer, format: int64, minimum: 0, description: Taille des fichiers retenus selon Kairnial. }
        parts:
          type: array
          description: Zips, présents quand l'export est `COMPLETE`.
          items:
            type: object
            required: [index, file_name, file_count, size_bytes]
            properties:
              index: { type: integer, format: int32, minimum: 1 }
              file_name: { type: string }
              file_count: { type: integer, format: int32, minimum: 0 }
              size_bytes: { type: integer, format: int64, minimum: 0 }
              download_url: { type: [string, "null"], format: uri, description: Lien signé ; nul si le stockage n'a pas répondu, relire l'export. }
              download_expires_at: { type: [string, "null"], format: date-time }
        skipped:
          type: array
          description: Fichiers que Kairnial n'a pas rendus, absents des zips.
          items:
            type: object
            properties:
              path: { type: [string, "null"] }
              file_name: { type: [string, "null"] }
              reason: { type: [string, "null"] }
        error: { type: [string, "null"], description: "Motif d'un export `ERROR`." }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time, description: Date d'effacement des zips. }
    VisaSheet:
      type: object
      required: [id, project_id, diffusion_no, status]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        model_id: { type: [string, "null"], format: uuid }
        revision_id: { type: [string, "null"], format: uuid }
        revision_version: { type: [integer, "null"], format: int32 }
        diffusion_no: { type: integer, format: int32 }
        phase: { type: [string, "null"], description: "`ESQ`, `APS`, `APD`, `PRO`, `EXE`, `DOE` ou `AUTRE`." }
        status: { type: string, description: "`DRAFT` ou `SIGNED`." }
        verdict: { type: [string, "null"], description: "`ACCEPTED`, `ACCEPTED_WITH_OBS` ou `REFUSED` ; posé à la signature." }
        score: { type: [number, "null"], minimum: 0, maximum: 1, description: Taux de conformité pondéré ; nul si aucun critère n'est évalué. }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
        signed_by: { type: [string, "null"] }
        signed_at: { type: [string, "null"], format: date-time }
        global_comment: { type: [string, "null"], description: Présent à la lecture d'une fiche. }
        items:
          type: array
          description: Critères évalués ; présent à la lecture d'une fiche.
          items:
            type: object
            required: [criterion_id]
            properties:
              criterion_id: { type: string }
              section: { type: [string, "null"] }
              label: { type: [string, "null"] }
              scope: { type: [string, "null"] }
              origin: { type: [string, "null"] }
              auto_check: { type: [string, "null"] }
              weight: { type: [integer, "null"], format: int32 }
              status: { type: [string, "null"], description: "`VSO`, `VAO`, `REFUSE`, `NC` ou `NA` ; nul tant que non évalué." }
              comment: { type: [string, "null"] }
              auto_proposal: { type: [string, "null"], description: Statut proposé par le contrôle automatique. }
              auto_summary: { type: [string, "null"] }
              evidence: { type: [string, "null"] }
        pieces:
          type: array
          description: Pièces attendues trouvées ou non ; présent à la lecture d'une fiche.
          items:
            type: object
            required: [piece_id]
            properties:
              piece_id: { type: string }
              label: { type: [string, "null"] }
              present: { type: [boolean, "null"] }
              source: { type: [string, "null"], description: "`AUTO` vu par le contrôle, `MANUAL` coché à la main ; nul tant que non vérifiée." }
              detail: { type: [string, "null"] }
        history:
          type: array
          description: Présent à la lecture d'une fiche.
          items:
            type: object
            required: [action]
            properties:
              action: { type: string, description: "`CREATED`, `SIGNED` ou `REOPENED`." }
              by: { type: [string, "null"] }
              at: { type: [string, "null"], format: date-time }
              reason: { type: [string, "null"], description: Motif d'une réouverture. }
        previous_sheet_id: { type: [string, "null"], format: uuid, description: Fiche de la diffusion précédente de la même maquette. }

    IssueBoard:
      type: object
      required: [id, name, source, editable, statuses, types, priorities]
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        source:
          type: string
          enum: [seveup, trimble, catenda]
          description: "`seveup` : tableau d'un espace, modifiable. `trimble`, `catenda` : projet synchronisé, lecture seule."
        space_id: { type: [string, "null"], format: uuid, description: Espace racine du tableau (`seveup` seulement). }
        editable: { type: boolean }
        statuses:
          type: array
          items:
            type: object
            required: [name, type]
            properties:
              name: { type: string }
              type: { type: [string, "null"], enum: [open, closed, null] }
        types: { type: array, items: { type: string } }
        priorities: { type: array, items: { type: string } }

    IssueComment:
      type: object
      required: [id, text, created_by, created_at]
      properties:
        id: { type: string, format: uuid }
        text: { type: string }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }

    Issue:
      type: object
      required: [id, board_id, ref, title, models, comment_count, viewpoint_count, editable]
      properties:
        id: { type: string, format: uuid }
        board_id: { type: string, format: uuid }
        ref: { type: [string, "null"], description: "Numéro affiché dans l'App, par exemple `#12`." }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        status: { type: [string, "null"], description: Nom du statut dans le tableau. }
        status_type: { type: [string, "null"], enum: [open, closed, null] }
        type: { type: [string, "null"] }
        priority: { type: [string, "null"] }
        assigned_to: { type: [string, "null"], description: Personne ou entreprise en charge (texte libre, souvent un e-mail). }
        due_date: { type: [string, "null"], format: date-time }
        storey: { type: [string, "null"], description: Niveau de la maquette. }
        models:
          type: array
          description: Maquettes (et révisions) sur lesquelles l'issue est posée.
          items:
            type: object
            required: [model_id]
            properties:
              model_id: { type: [string, "null"], format: uuid }
              revision_id: { type: [string, "null"], format: uuid }
        comment_count: { type: integer, format: int32, minimum: 0 }
        viewpoint_count: { type: integer, format: int32, minimum: 0, description: Points de vue 3D enregistrés dans le viewer. }
        editable: { type: boolean, description: Faux pour une issue d'un projet Trimble ou Catenda synchronisé. }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        modified_by: { type: [string, "null"] }
        modified_at: { type: [string, "null"], format: date-time }
        comments:
          type: array
          description: Présent à la lecture d'une issue, absent des listes.
          items: { $ref: "#/components/schemas/IssueComment" }

    IssueCreate:
      type: object
      required: [model_id, title]
      additionalProperties: false
      properties:
        model_id: { type: string, format: uuid }
        revision_id: { type: [string, "null"], format: uuid, description: Révision de cette maquette ; la dernière si absent. }
        title: { type: string, minLength: 1, maxLength: 1024 }
        description: { type: [string, "null"], maxLength: 20000 }
        type: { type: [string, "null"], maxLength: 255, description: "Nom d'un type du tableau ; `Remarque` si absent." }
        priority: { type: [string, "null"], enum: [Basse, Normale, Haute, Critique, null] }
        assigned_to: { type: [string, "null"], maxLength: 255 }
        due_date: { type: [string, "null"], format: date-time }

    IssueUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 1024 }
        description: { type: [string, "null"], maxLength: 20000 }
        status: { type: string, maxLength: 255, description: Nom d'un statut du tableau. }
        type: { type: string, maxLength: 255, description: Nom d'un type du tableau. }
        priority: { type: string, enum: [Basse, Normale, Haute, Critique] }
        assigned_to: { type: [string, "null"], maxLength: 255 }
        due_date: { type: [string, "null"], format: date-time }

    IssueCommentCreate:
      type: object
      required: [text]
      additionalProperties: false
      properties:
        text: { type: string, minLength: 1, maxLength: 20000 }

    WebhookEventType:
      type: string
      enum:
        - revision.processing.completed
        - revision.processing.failed
        - ids_check.completed
        - ids_check.failed
        - export.ready

    Webhook:
      type: object
      required: [id, name, url, events, active, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        active: { type: boolean }
        last_delivered_at: { type: [string, "null"], format: date-time }
        last_status: { type: [integer, "null"], format: int32 }
        created_at: { type: string, format: date-time }

    WebhookCreate:
      type: object
      required: [name, url, events]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, format: uri, description: "HTTPS uniquement, 2000 caractères au plus." }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: "#/components/schemas/WebhookEventType" }

    WebhookDelivery:
      type: object
      required: [id, event, status, attempts, created_at]
      properties:
        id: { type: string, format: uuid, description: "Valeur de l'en-tête `webhook-id`." }
        event: { type: string }
        status:
          type: string
          enum: [pending, delivered, failed, abandoned, refused]
          description: |
            `pending` en file ou en attente d'un nouvel essai ; `delivered` reçu en `2xx` ; `failed` refusé par le
            destinataire (`4xx`) ; `abandoned` essais épuisés ou abonnement supprimé ; `refused` URL non publique.
        attempts: { type: integer, format: int32, minimum: 0 }
        response_code: { type: [integer, "null"], format: int32 }
        error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        next_attempt_at: { type: [string, "null"], format: date-time }

    WebhookEvent:
      type: object
      required: [type, timestamp, data]
      properties:
        type: { $ref: "#/components/schemas/WebhookEventType" }
        timestamp: { type: string, format: date-time }
        data:
          type: object
          additionalProperties: true
