# Captiv API v1 — OpenAPI 3.1 (P563)
# Source de vérité machine-lisible du contrat. Servie sur /openapi/v1.yaml.
# Ne décrit QUE ce qui est implémenté — la roadmap vit dans docs/API_V1_FONDATIONS.md.
openapi: 3.1.0
info:
  title: Captiv API
  version: "1.0"
  description: |
    API publique Captiv v1 — leads, posts et (à venir) ressources, analytics, webhooks.

    **Auth** : `Authorization: Bearer cptv_live_…` (clé créée dans Réglages, plan Pro/Agence requis).
    **Succès** : enveloppe `{ data, meta }`. **Erreurs** : RFC 7807 `application/problem+json`.
    **Pagination** : curseur opaque (`meta.pagination.next_cursor`), jamais d'offset.
    **Rate limit** : global 1 000 req/h (Pro) / 10 000 (Agence) par clé — headers
    `X-RateLimit-*`. S'y ajoute une grille PAR CLASSE et PAR WORKSPACE (P586) :
    écritures 300/1 500 par heure, téléversements 60/200, générations par type
    (posts 10/30, ressources 3/8, questions de cadrage 60/200). Les en-têtes `X-RateLimit-Class-*` disent
    toujours où en est le compteur qui mordra en premier sur l'endpoint appelé ;
    un 429 nomme la classe dans `rate_limit.class`.
    **Texte tiers** : les champs écrits par des inconnus (noms, titres,
    commentaires de leads) sont assainis en sortie — caractères invisibles et
    surcharges bidirectionnelles retirés, longueur bornée. Les emojis et les
    alphabets non latins passent intacts.
    **Maintenance** : tout endpoint peut répondre `503 api_disabled` (problem+json,
    `Retry-After: 300`) pendant une coupure volontaire de l'API.
  contact:
    email: support@captiv-ai.com
servers:
  - url: https://captiv-ai.com/api/v1
security:
  - apiKey: []
tags:
  - name: me
    description: Introspection de la clé
  - name: leads
    description: Leads capturés par vos posts
  - name: posts
    description: Posts LinkedIn surveillés
  - name: resources
    description: Ressources (lead magnets)
  - name: studio
    description: Création de posts (questions, génération)
  - name: follow-ups
    description: Liste des relances (intentions — l'agent exécute, jamais l'API)
  - name: analytics
    description: Métriques agrégées du workspace

paths:
  /me:
    get:
      tags: [me]
      operationId: getMe
      summary: Introspection de la clé (workspace, plan, scopes)
      description: Aucun scope requis — endpoint de smoke test des intégrations.
      responses:
        "200":
          description: Contexte de la clé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Me"
                  meta:
                    $ref: "#/components/schemas/Meta"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /leads:
    get:
      tags: [leads]
      operationId: listLeads
      summary: Liste paginée des leads du workspace
      description: Scope requis — `read:leads`. Tri stable (created_at, id).
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Compact"
        - name: post_id
          in: query
          schema: { type: string, format: uuid }
          description: Ne retourner que les leads d'un post
        - name: min_score
          in: query
          schema: { type: integer, minimum: 0, maximum: 100 }
          description: >-
            lead_score minimal. ATTENTION : lead_score mesure l'ADÉQUATION AU PROFIL
            recherché (ICP), pas l'engagement. Un lead à 95 peut n'avoir jamais rien fait.
            « VIP » = 85 et plus. Pour l'engagement, voir min_interactions /
            resource_opened ici, et min_engagement sur /lead-interactions.
        - name: min_interactions
          in: query
          schema: { type: integer, minimum: 0, maximum: 1000 }
          description: Nombre minimal d'interactions du lead — un vrai signal de comportement.
        - name: resource_opened
          in: query
          schema: { type: string, enum: ["true", "false"] }
          description: Ne garder que les leads qui ont ouvert (ou non) la ressource.
        - name: search
          in: query
          schema: { type: string, maxLength: 200 }
          description: >-
            Recherche sur le prénom, le nom et la société (insensible à la casse).
            Le terme est assaini avant usage : seuls lettres, chiffres, espace, apostrophe,
            trait d'union et esperluette sont conservés — les caractères de grammaire du
            filtre ne peuvent pas le réécrire. Deux caractères utiles minimum, sinon le
            filtre est ignoré (jamais élargi).
        - name: connection_status
          in: query
          schema:
            type: string
            enum:
              [connected, pending, invitation_ignored, not_requested, not_connected,
               unreachable_locked, unreachable_no_urn]
          description: >-
            Où en est la relation LinkedIn. `not_requested` = jamais invité,
            `invitation_ignored` = invitation restée sans suite.
        - name: since
          in: query
          schema: { type: string, format: date-time }
          description: created_at ≥ since (ISO 8601)
        - name: until
          in: query
          schema: { type: string, format: date-time }
          description: created_at ≤ until (ISO 8601)
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page de leads
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Lead" }
                  meta:
                    $ref: "#/components/schemas/Meta"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanRequired" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /leads/{id}:
    get:
      tags: [leads]
      operationId: getLead
      summary: Détail d'un lead
      description: |
        Scope requis — `read:leads`. Un lead d'un autre workspace répond 404
        (l'existence n'est jamais confirmée).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Le lead
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Lead" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts:
    get:
      tags: [posts]
      operationId: listPosts
      summary: Liste paginée des posts du workspace
      description: Scope requis — `read:posts`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Compact"
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, published, monitoring, completed, scheduled, publishing, failed]
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: until
          in: query
          schema: { type: string, format: date-time }
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page de posts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Post" }
                  meta:
                    $ref: "#/components/schemas/Meta"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /leads/{id}/notes:
    get:
      tags: [leads]
      operationId: listLeadNotes
      summary: Notes d'un lead (100 max, plus récentes d'abord)
      description: Scope requis — `read:leads`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Notes du lead
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LeadNote" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [leads]
      operationId: createLeadNote
      summary: Ajouter une note à un lead
      description: |
        Scope requis — `write:leads`. La note est attribuée au créateur de la
        clé et marquée source `api` pour l'audit.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string, minLength: 1, maxLength: 2000 }
      responses:
        "201":
          description: Note créée
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/LeadNote" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-interactions:
    get:
      tags: [leads]
      operationId: listLeadInteractions
      summary: Liste paginée des interactions (lead × post)
      description: Scope requis — `read:leads`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Compact"
        - name: lead_id
          in: query
          schema: { type: string, format: uuid }
        - name: post_id
          in: query
          schema: { type: string, format: uuid }
        - name: min_engagement
          in: query
          schema: { type: integer, minimum: 0, maximum: 100 }
          description: >-
            engagement_score minimal de l'interaction. C'EST LE FILTRE D'ENGAGEMENT
            de l'API : le score est fin (0-94 observé en production) et propre à chaque
            interaction. Ne pas confondre avec les scores portés par le lead, qui
            mesurent l'adéquation au profil.
        - name: has_comment
          in: query
          schema: { type: string, enum: ["true", "false"] }
          description: Ne garder que les interactions où le lead a écrit un commentaire.
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: until
          in: query
          schema: { type: string, format: date-time }
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page d'interactions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LeadInteraction" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/{id}:
    get:
      tags: [posts]
      operationId: getPost
      summary: Détail d'un post + stats LinkedIn natives
      description: Scope requis — `read:posts`. Autre workspace → 404.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Le post
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [posts]
      operationId: updatePost
      summary: Enregistrer un texte de post retravaillé
      description: >-
        Scope requis — `write:posts`. **N'appelle aucun modèle et ne consomme aucun crédit IA.**
        C'est l'équivalent de taper dans l'éditeur du Studio : le texte fourni est rangé tel quel.
        La génération (`POST /v1/studio/posts`) consomme le pool IA du workspace ; l'édition, non.


        Fournir `expected_updated_at` (lu sur le `GET`) : sans lui, une modification faite au
        même moment dans l'écran Captiv serait écrasée en silence. Un `409` signifie que le post
        a bougé — relire et rejouer l'édition sur la version fraîche.


        Refusé (`409`) sur un post déjà publié ou en cours de publication, et sur un post suivi
        depuis LinkedIn plutôt qu'écrit dans le Studio.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                text:
                  type: string
                  maxLength: 20000
                  description: Le texte retravaillé, complet.
                subject: { type: string, maxLength: 300 }
                expected_updated_at:
                  type: string
                  maxLength: 40
                  description: Le `updated_at` lu au GET. Verrou optimiste.
      responses:
        "200":
          description: Le post à jour (avec son nouveau `updated_at`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Post publié, suivi depuis LinkedIn, ou modifié depuis la lecture
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/{id}/visual:
    post:
      tags: [posts]
      operationId: attachPostVisual
      summary: Transmettre un visuel à un post Studio (base64 ou URL re-hébergée)
      description: |
        Scope requis — `write:posts`. Le format est SNIFFÉ (jamais le MIME
        déclaré), l'image est ré-encodée puis hébergée chez Captiv (une URL
        externe n'entre jamais telle quelle : la publication programmée en
        dépend). Règle LinkedIn appliquée : images OU vidéo, jamais les deux ;
        9 visuels max ; un post publié ne change plus (409).

        **Concurrence (P613).** Un carrousel se monte par appels successifs —
        neuf images à 8 Mo ne tiennent pas dans une requête. L'écriture est
        donc conditionnée à l'état lu : deux attaches simultanées ne peuvent
        plus se perdre, celle qui arrive seconde rejoue sa fusion sur l'état
        frais **sans re-téléverser**. Aucun paramètre à fournir, le verrou est
        interne. Un `409` de concurrence n'apparaît qu'après plusieurs
        tentatives infructueuses — il se rejoue tel quel.

        L'ordre des appels reste l'ordre du carrousel : attendre la réponse de
        chacun avant d'envoyer le suivant. Le verrou empêche la perte, pas le
        désordre.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                image_base64:
                  type: string
                  description: Image encodée base64 (≤ 8 Mo décodés) — le cas « visuel fait dans Claude »
                url:
                  type: string
                  description: URL https publique — l'image est re-téléchargée et re-hébergée
                mode:
                  type: string
                  enum: [append, replace_cover]
                  default: append
      responses:
        "201":
          description: Visuel attaché
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostVisual" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

    delete:
      tags: [posts]
      operationId: clearPostVisuals
      summary: Retirer tous les visuels d'un post
      description: >-
        Scope requis — `write:posts`. Aucun coût IA. Vide l'attache visuelle du post
        (couverture comprise) pour repartir proprement.


        Le cas d'usage principal est le carrousel : neuf images à 8 Mo ne tiennent pas dans une
        requête, l'envoi est donc séquentiel — et un envoi qui échoue en cours de route laissait
        un carrousel amputé sans moyen de le reprendre. `replace_cover` ne remplace que la
        première image.


        Le texte du post n'est pas touché. Refusé (`409`) sur un post publié, en cours de
        publication, ou suivi depuis LinkedIn.
      responses:
        "200":
          description: Visuels retirés
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      object: { type: string, const: post_visual }
                      post_id: { type: string, format: uuid }
                      removed:
                        type: integer
                        description: Nombre de visuels qui viennent d'être retirés.
                      visual_urls: { type: array, items: { type: string } }
                      cover_url: { type: ["string", "null"] }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources:
    get:
      tags: [resources]
      operationId: listResources
      summary: Liste paginée des ressources (lead magnets)
      description: Scope requis — `read:resources`. Métadonnées seulement, jamais le contenu des blocs.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Compact"
        - name: post_id
          in: query
          schema: { type: string, format: uuid }
        - name: is_published
          in: query
          schema: { type: string, enum: ["true", "false"] }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: until
          in: query
          schema: { type: string, format: date-time }
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page de ressources
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Resource" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

    post:
      tags: [resources]
      operationId: createResource
      summary: Créer une ressource (lead magnet) — 202, génération en fond
      description: |
        Scope requis — `generate:resources`. LE MÊME moteur que l'écran
        (voix du créateur, charte, mouvement structurel). La ressource naît
        TOUJOURS en brouillon : la publier est un geste explicite dans
        Captiv, jamais un effet de bord — et elle n'est jamais attachée à un
        post à la naissance. Réponse 202 immédiate ; suivre la génération
        (25-96 s mesurés) via `GET /v1/resources/{id}/status`, puis éditer
        les blocs et demander un aperçu signé. En-tête `Idempotency-Key`
        recommandé : rejouer la même clé rend la même réponse, jamais deux
        ressources. Chaque appel réserve 0,50 $ sur le pool IA quotidien du
        workspace (réglé au coût réel en fin de génération) et compte dans le
        quota ressources/jour du plan. Lot actuel : matière texte (titre,
        brief, liens) — médias et documents viendront avec l'infra d'upload.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, minLength: 2, maxLength: 200 }
                subtitle: { type: ["string", "null"], maxLength: 800 }
                extra_details:
                  type: string
                  maxLength: 6000
                  description: Le brief — plus il a de vécu (chiffres, scènes, verbatims), meilleure est la ressource
                language: { type: string, enum: [fr, en], default: fr }
                resource_type: { type: string, maxLength: 40, default: guide }
                external_links:
                  type: array
                  maxItems: 5
                  items:
                    type: object
                    required: [url]
                    properties:
                      url: { type: string, maxLength: 2000 }
                      title: { type: string, maxLength: 200 }
                design:
                  type: object
                  description: Préréglages visuels ; absent = dérivé de la charte du créateur
                  properties:
                    font: { type: string }
                    palette: { type: string }
                    density: { type: string }
      responses:
        "202":
          description: Ressource créée en brouillon, génération lancée
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ResourceGenerationJob" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}:
    get:
      tags: [resources]
      operationId: getResource
      summary: Détail d'une ressource (+ blocks_count et updated_at du verrou)
      description: |
        Scope requis — `read:resources`. `updated_at` est l'echo du verrou
        optimiste : à renvoyer tel quel en `expected_updated_at` sur toute
        écriture de blocs.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: La ressource
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ResourceDetail" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/blocks:
    get:
      tags: [resources]
      operationId: listResourceBlocks
      summary: Les blocs de la ressource (id, type, content, metadata)
      description: Scope requis — `read:resources`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Blocs + updated_at (verrou optimiste)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ResourceBlocks" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [resources]
      operationId: createResourceBlock
      summary: Insérer un bloc (édition directe, aucune IA côté Captiv)
      description: |
        Scope requis — `write:resources`. Type dans l'allowlist partagée avec
        le générateur (`calendar_cta` réservé au moteur V2 du propriétaire),
        content JSON validé pour les types qui en stockent, sanitisation
        éditoriale appliquée (les transformations sont retournées dans
        `sanitize_flags`). Verrou optimiste via `expected_updated_at`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type: { type: string }
                content: { type: string, maxLength: 50000, default: "" }
                metadata: { type: object, additionalProperties: true }
                position: { type: integer, minimum: 0, description: "Défaut : fin" }
                expected_updated_at: { type: string }
      responses:
        "201":
          description: Bloc créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BlockWriteResult" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/blocks/{blockId}:
    patch:
      tags: [resources]
      operationId: updateResourceBlock
      summary: Éditer le contenu et/ou la metadata d'un bloc (type immuable)
      description: |
        Scope requis — `write:resources`. Les clés metadata fournies sont
        fusionnées en surface, les clés absentes préservées. Verrou optimiste.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: blockId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string, maxLength: 50000 }
                metadata: { type: object, additionalProperties: true }
                expected_updated_at: { type: string }
      responses:
        "200":
          description: Bloc mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BlockWriteResult" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [resources]
      operationId: deleteResourceBlock
      summary: Supprimer un bloc
      description: |
        Scope requis — `write:resources`. `expected_updated_at` passe en query
        (un DELETE n'a pas toujours de body).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: blockId
          in: path
          required: true
          schema: { type: string }
        - name: expected_updated_at
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Bloc supprimé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
                      block_id: { type: string }
                      blocks_count: { type: integer }
                      updated_at: { type: string, format: date-time }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/blocks/order:
    put:
      tags: [resources]
      operationId: reorderResourceBlocks
      summary: Réordonner les blocs (permutation stricte des ids existants)
      description: |
        Scope requis — `write:resources`. `block_ids` doit contenir exactement
        les ids existants — ni manquant, ni inconnu, ni doublon.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [block_ids]
              properties:
                block_ids:
                  type: array
                  items: { type: string }
                expected_updated_at: { type: string }
      responses:
        "200":
          description: Ordre appliqué
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reordered: { type: boolean }
                      blocks_count: { type: integer }
                      updated_at: { type: string, format: date-time }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/render-context:
    get:
      tags: [resources]
      operationId: getResourceRenderContext
      summary: De quoi reproduire fidèlement la page de la ressource
      description: >-
        Scope requis — `read:resources`. Les blocs disent CE QUI est écrit ; ceci dit à quoi
        la page RESSEMBLE : thème résolu (variables CSS), carte de l'auteur, identité de la
        ressource. À appeler avant de construire un aperçu — sans lui, l'aperçu est
        structurellement juste et visuellement faux.


        `theme.frozen: true` signifie que la charte a été FIGÉE à la publication : c'est cette
        apparence-là qu'ont reçue les leads, pas la charte actuelle du créateur.
        `css_variables` vide n'est pas une erreur — la ressource utilise le rendu par défaut.


        Ne sort jamais : le brief privé du créateur (`resources.metadata`), dont seules les
        deux clés de thème sont lues. Les variables CSS sont filtrées au préfixe
        `--resource-` et bornées en longueur.
      responses:
        "200":
          description: Le contexte de rendu
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      object: { type: string, const: resource_render_context }
                      resource_id: { type: string, format: uuid }
                      updated_at: { type: ["string", "null"], format: date-time }
                      format_version: { type: ["integer", "null"] }
                      resource: { type: object }
                      theme:
                        type: object
                        properties:
                          css_variables:
                            type: object
                            additionalProperties: { type: string }
                          frozen: { type: boolean }
                      author: { type: ["object", "null"] }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  /resources/{id}/lint:
    get:
      tags: [resources]
      operationId: lintResource
      summary: Auto-vérification avant publication (même lint que le produit)
      description: |
        Scope requis — `read:resources`. Le lint constate, il ne bloque jamais
        l'édition. La matière privée (brief) est consommée côté serveur et ne
        sort pas.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Constats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      resource_id: { type: string, format: uuid }
                      blocks_count: { type: integer }
                      clean: { type: boolean }
                      findings:
                        type: array
                        items: { type: object, additionalProperties: true }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/preview:
    post:
      tags: [resources]
      operationId: createResourcePreview
      summary: Lien de préversion signé de la vraie page /r/ (45 min)
      description: |
        Scope requis — `read:resources`. Fidélité 100 % par construction :
        c'est la page que verra le lead, rendue avec la charte du créateur.
        Le lien n'ouvre que cette ressource, expire, n'est pas indexable et
        ne compte aucune vue. Ce n'est PAS un lien à envoyer à un prospect.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "201":
          description: Lien de préversion
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ResourcePreview" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /resources/{id}/status:
    get:
      tags: [resources]
      operationId: getResourceGenerationStatus
      summary: Où en est la génération de la ressource (pattern job)
      description: |
        Scope requis — `read:resources`. La génération d'une ressource tourne
        en fond (25-96 s mesurés) : cette route rend les FAITS du lifecycle
        (state, horodatages, blocs réellement en base) sans marteler la
        ressource entière. `state: idle` = ressource sans marqueur (créée
        avec ses blocs, ou née avant le lifecycle) — l'absence de fait n'est
        jamais inventée en état.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: L'état de génération
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ResourceGenerationStatus" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /studio/questions:
    post:
      tags: [studio]
      operationId: createPostQuestions
      summary: Les 3 questions de cadrage personnalisées pour une idée de post
      description: |
        Scope requis — `generate:posts`. Chaque question reprend les mots
        exacts de l'idée et vise la matière que seul le créateur détient
        (chiffre, scène, verbatim). Si le sur-mesure échoue, le serveur
        replie sur la banque déterministe : la réponse porte TOUJOURS
        3 questions, avec sa provenance (`source`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idea]
              properties:
                idea: { type: string, minLength: 20, maxLength: 1200 }
                is_lead_magnet: { type: boolean, default: false }
      responses:
        "200":
          description: Les 3 questions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostQuestions" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /studio/posts:
    post:
      tags: [studio]
      operationId: generatePost
      summary: Générer un post LinkedIn complet (voix du créateur, moteur Studio)
      description: |
        Scope requis — `generate:posts`. LE MÊME moteur que l'écran Studio :
        voix apprise du propriétaire du workspace, cadrage (cf.
        `/studio/questions`), correction anti-tics, extraction du mot à
        commenter pour un lead magnet. Le post créé est un brouillon Studio
        normal, visible dans « Mes posts », prêt à éditer et publier.

        Fortement recommandé — en-tête `Idempotency-Key` : rejouer la même clé
        rend la MÊME réponse, jamais deux posts.

        **Depuis P613, le connecteur MCP dérive cette clé du GESTE** (empreinte
        de la méthode, du chemin et du corps canonique) au lieu d'en tirer une
        au hasard. C'est ce qui rend le rejeu utile : un agent qui rappelle
        l'outil après un délai dépassé portait jusque-là une clé neuve, donc
        payait une seconde génération complète. Conséquence assumée : deux
        générations *volontairement* identiques dans la fenêtre d'idempotence
        rejouent la première — passer une `idempotency_key` explicite pour en
        forcer une seconde.

        Comptabilité : chaque appel réserve son coût sur le pool IA quotidien
        du workspace (partagé avec l'app) et compte dans le quota
        posts/jour du plan. Refus en 429 `daily_budget_exceeded`,
        `generation_quota_exceeded` ou `concurrency_limit_exceeded`.

        Une réservation est TOUJOURS rendue : depuis P612 le garde l'annule dès
        que la réponse est une erreur, y compris sur un payload invalide qui
        n'atteint jamais le moteur.

        Quand l'idée est trop mince, le moteur répond `200` avec
        `status: needs_more_input` et un `feedback` actionnable — aucun post
        n'est créé, enrichissez l'idée et rappelez.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idea]
              properties:
                idea: { type: string, minLength: 10, maxLength: 4000 }
                language: { type: string, enum: [fr, en], default: fr }
                resource_id:
                  type: string
                  format: uuid
                  description: |
                    Ressource PUBLIÉE du workspace à attacher (flux lead
                    magnet — le CTA oriente vers son mot à commenter)
                is_lead_magnet:
                  type: boolean
                  default: false
                  description: Intention lead magnet sans ressource encore créée
                cadrage:
                  type: array
                  maxItems: 3
                  description: Les réponses aux questions de /studio/questions
                  items:
                    type: object
                    required: [question, reponse]
                    properties:
                      question: { type: string, maxLength: 400 }
                      reponse: { type: string, maxLength: 2000 }
                style:
                  type: object
                  properties:
                    forme: { type: string, maxLength: 80 }
                    ton: { type: string, maxLength: 80 }
      responses:
        "201":
          description: Post généré et enregistré en brouillon Studio
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostGeneration" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "200":
          description: "`status: needs_more_input` — le feedback dit quoi enrichir"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PostGeneration" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /follow-ups:
    get:
      tags: [follow-ups]
      operationId: listFollowUps
      summary: La liste des follow-ups en attente
      description: |
        Scope requis — `read:followups`. Ce qui a été mis en file, avec le
        jour prévu par la dispersion. `scheduled_for` est une PRÉVISION : la
        place réelle est recalculée par l'agent au moment de planifier.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { type: string, enum: [queued, planned, sent, cancelled, skipped] }
        - name: lead_id
          in: query
          schema: { type: string, format: uuid }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: until
          in: query
          schema: { type: string, format: date-time }
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page d'intentions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/FollowUpIntent" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [follow-ups]
      operationId: queueFollowUp
      summary: Ajouter un lead à la liste des follow-ups
      description: |
        Scope requis — `write:followups`, volontairement SÉPARÉ de
        `write:leads` : inscrire quelqu'un en file de relance engage un
        message LinkedIn en son nom, ce n'est pas une écriture ordinaire.

        **Cet endpoint n'envoie RIEN.** Il pose une intention. L'agent Captiv
        exécute plus tard, étalé, sous ses caps quotidiens, son jitter, ses
        horaires ouvrés et son circuit-breaker. Aucun chemin externe ne peut
        déclencher un envoi LinkedIn — c'est un invariant du produit.

        Le message est composé à l'ajout et stocké : il est relisible et
        modifiable avant de partir. Son accroche suit le fait RÉELLEMENT
        observé (ressource ouverte, seulement envoyée, ou simple commentaire) —
        elle n'affirme jamais une lecture qui n'a pas eu lieu.

        Un lead déjà en file répond `409` : un prospect ne doit pas recevoir
        deux fois la même relance.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lead_id]
              properties:
                lead_id: { type: string, format: uuid }
                message:
                  type: string
                  maxLength: 420
                  description: |
                    Message imposé. Passe par le même filtre de forme que
                    celui du modèle ; hors forme, un message correct est
                    composé à la place plutôt que de perdre l'intention.
      responses:
        "201":
          description: Intention mise en file
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/FollowUpQueued" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /follow-ups/{id}:
    delete:
      tags: [follow-ups]
      operationId: cancelFollowUp
      summary: Retirer une intention de la liste
      description: |
        Scope requis — `write:followups`. La ligne n'est pas supprimée : elle
        passe en `cancelled` (l'historique de ce qui a été demandé puis retiré
        doit rester explicable), ce qui libère le lead pour un futur ajout.
        Une relance DÉJÀ partie ne s'annule pas — le message est chez le
        prospect (409).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Intention annulée (ou déjà annulée)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/FollowUpCancelled" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/summary:
    get:
      tags: [analytics]
      operationId: getAnalyticsSummary
      summary: KPI agrégés du workspace
      description: |
        Scope requis — `read:analytics`. Mêmes formules que le dashboard
        Captiv (moteur de calcul partagé).

        **Fraîcheur (P613).** Le calcul est mémorisé 60 secondes par
        (workspace, période) : une boucle d'agent qui redemande les mêmes
        chiffres ne les repaie plus intégralement. La réponse est alors servie
        **verbatim**, fenêtre comprise — rien n'est recalculé, donc rien ne
        peut se contredire.

        Conséquence à connaître : sur une réponse servie de la mémoire, `to`
        est l'instant du **calcul**, pas celui de la requête. L'en-tête `Age`
        (RFC 9111, en secondes) dit de combien : `Age: 0` sur un calcul frais.

        **Chiffres manquants (P614).** Si une lecture de compteur échoue, le
        champ vaut `null` et `partial` passe à `true` — jamais `0`. Un zéro se
        lit « il ne s'est rien passé », et c'est une phrase sur laquelle on
        décide. Une réponse partielle n'est pas mémorisée.
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [7d, 30d, 90d], default: 30d }
      responses:
        "200":
          description: KPI de la période
          headers:
            Age:
              description: >-
                Secondes écoulées depuis le calcul de ces chiffres. `0` = calculé
                à l'instant. Jamais supérieur à 60.
              schema: { type: integer, minimum: 0, maximum: 60 }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AnalyticsSummary" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ══ L'agent (P605) — LECTURE SEULE, DÉFINITIVEMENT ═════════════════════════
  # Aucun POST, PATCH ou DELETE n'existe sous /agent, et il ne doit jamais en
  # apparaître : reprogrammer ou déclencher une action LinkedIn depuis un client
  # tiers mettrait les garde-fous anti-ban entre des mains extérieures.
  /agent/actions:
    get:
      tags: [agent]
      operationId: listAgentActions
      summary: Ce que l'agent a prévu et ce qu'il a fait
      description: >-
        Scope requis — `read:agent`. Invitations, messages, commentaires et vérifications
        planifiés par l'agent, avec leur statut et, le cas échéant, la raison d'un saut.
        Le texte des messages n'est pas exposé.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Compact"
        - name: lead_id
          in: query
          schema: { type: string, format: uuid }
        - name: post_id
          in: query
          schema: { type: string, format: uuid }
        - name: action_type
          in: query
          schema:
            type: string
            enum: [invitation, message, comment, check, followup_notif]
        - name: status
          in: query
          schema: { type: string, maxLength: 40 }
          description: "pending, done, skipped…"
        - name: scheduled_since
          in: query
          schema: { type: string, format: date-time }
          description: Fenêtre sur la date PRÉVUE (pas la création) — c'est elle qui répond à « aujourd'hui ».
        - name: scheduled_until
          in: query
          schema: { type: string, format: date-time }
        - name: order
          in: query
          schema: { type: string, enum: [created_desc, created_asc], default: created_desc }
      responses:
        "200":
          description: Page d'actions de l'agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object } }
                  meta: { $ref: "#/components/schemas/Meta" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /agent/status:
    get:
      tags: [agent]
      operationId: getAgentStatus
      summary: État du compte qui agit et plafonds anti-ban
      description: >-
        Scope requis — `read:agent`. Compte connecté ou non, score de santé, jour de chauffe,
        plafonds d'invitations/messages/commentaires, et nombre d'actions en attente.
        Aucun identifiant de connexion ni nom de prestataire n'est exposé.
      responses:
        "200":
          description: État de l'agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: "cptv_live_… (43-char base64url secret)"

    # P595 — le second porteur accepté : le jeton d'un connecteur MCP autorisé
    # en OAuth 2.1 depuis claude.ai. Il n'ouvre aucune capacité nouvelle : il se
    # résout vers une clé du workspace, et plan, scopes, quotas, budget IA,
    # idempotence et audit s'appliquent à l'identique. Seuls les scopes
    # RÉELLEMENT cochés à l'écran de consentement sont actifs.
    oauthConnecteur:
      type: oauth2
      description: |
        Réservé aux connecteurs MCP (claude.ai). Client public pré-enregistré
        `captiv-mcp`, PKCE S256 obligatoire, aucun enregistrement dynamique.
        Découverte : /.well-known/oauth-protected-resource/api/mcp puis
        /.well-known/oauth-authorization-server.
      flows:
        authorizationCode:
          authorizationUrl: https://captiv-ai.com/oauth/authorize
          tokenUrl: https://captiv-ai.com/api/oauth/token
          refreshUrl: https://captiv-ai.com/api/oauth/token
          # Exactement SCOPES_MCP (lib/mcp/oauth.ts) : tous les scopes de l'API
          # SAUF write:webhooks, dont aucun outil MCP ne se sert. Verrouillé par
          # tests/les-metadonnees-oauth-disent-la-verite.test.ts — un écart ici
          # rendrait la doc menteuse en silence.
          scopes:
            read:leads: Lire les leads du workspace (liste, détail, interactions)
            write:leads: Qualifier un lead, ajouter des notes
            read:posts: Lire les posts surveillés et leurs métriques
            write:posts: Attacher un visuel à un post Studio (sans coût IA)
            read:resources: Lire les ressources (lead magnets) et leurs stats
            write:resources: Éditer les blocs d'une ressource (contenu, ordre, ajout, suppression)
            read:analytics: Lire les métriques agrégées du workspace
            generate:posts: Générer des posts et leurs questions de cadrage — consomme le budget IA du workspace
            generate:resources: Générer des ressources (lead magnets) — consomme le budget IA du workspace
            read:followups: Lire la liste des follow-ups en attente
            write:followups: Ajouter un lead à la liste des follow-ups (l'agent relancera plus tard, sous ses caps)
            read:agent: Lire l'activité de l'agent — ce qu'il a prévu, ce qu'il a fait, la santé du compte LinkedIn et les quotas anti-ban (lecture seule ; il n'existe pas de write:agent)

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string, maxLength: 300 }
      description: Curseur opaque issu de meta.pagination.next_cursor
    Compact:
      name: compact
      in: query
      schema: { type: string, enum: ["true", "false"], default: "false" }
      description: |
        Projection réduite, pensée pour les agents : seuls les champs qui
        servent à DÉCIDER (identité, score, état). Un modèle qui lit 50 objets
        complets brûle des milliers de jetons en champs inutilisés ; le détail
        reste disponible via le GET par id. Le serveur MCP l'active par défaut.

        Pour un lead, la projection porte aussi `interaction_count` et
        `meeting_booked` : sans eux, une liste ne contenait AUCUN signal de
        comportement, et un agent n'avait d'autre choix que de raisonner sur le
        score de profil en croyant parler d'engagement.

        L'absence d'un champ en mode compact n'est PAS une valeur nulle.

  responses:
    Unauthorized:
      description: Clé absente, malformée, inconnue, révoquée ou expirée
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    PlanRequired:
      description: L'API requiert un plan Pro ou Agence actif
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    InsufficientScope:
      description: La clé ne porte pas le scope requis
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Ressource introuvable dans ce workspace
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationFailed:
      description: Paramètres invalides (détail par champ dans errors[])
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Rate limit dépassé — voir Retry-After et X-RateLimit-*
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: |
        La ressource a changé depuis votre lecture (verrou optimiste) —
        relire les blocs et rejouer avec le updated_at frais
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      description: RFC 7807 Problem Details
      required: [type, title, status, detail, request_id]
      properties:
        type: { type: string, format: uri, description: "Ancre de doc stable du code d'erreur" }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        request_id: { type: string, description: "À fournir au support" }
        errors:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              message: { type: string }

    Meta:
      type: object
      required: [request_id]
      properties:
        request_id: { type: string }
        pagination:
          type: object
          properties:
            next_cursor: { type: ["string", "null"] }
            has_more: { type: boolean }

    Me:
      type: object
      properties:
        object: { type: string, const: me }
        workspace:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            plan: { type: string, enum: [pro, agency] }
        api_key:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            environment: { type: string, enum: [live, test] }
            scopes:
              type: array
              items: { type: string }
            created_at: { type: string, format: date-time }

    Lead:
      type: object
      description: |
        Un lead capturé. Trois scores distincts sont exposés — lead_score
        (source de vérité, 0-100), engagement_score (profil + comportement,
        0-100), icp_fit_score (adéquation ICP, 0-100). Les autres scores
        internes ne font pas partie du contrat.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: lead }
        post_id: { type: ["string", "null"], format: uuid }
        created_at: { type: string, format: date-time }
        source: { type: ["string", "null"] }
        profile:
          type: object
          properties:
            first_name: { type: ["string", "null"] }
            last_name: { type: ["string", "null"] }
            job_title: { type: ["string", "null"] }
            company: { type: ["string", "null"] }
            linkedin_url: { type: ["string", "null"] }
            photo_url: { type: ["string", "null"] }
            location_city: { type: ["string", "null"] }
            location_country: { type: ["string", "null"] }
            location_full: { type: ["string", "null"] }
            current_position: { type: ["string", "null"] }
            experience_years: { type: ["number", "null"] }
            connections_count: { type: ["number", "null"] }
            enriched:
              type: boolean
              description: L'enrichissement a abouti. Sans lui, un champ vide est ambigu.
        company_details:
          type: object
          description: Firmographie, telle que l'écran la montre.
          properties:
            domain: { type: ["string", "null"] }
            industry: { type: ["string", "null"] }
            employee_count: { type: ["string", "null"] }
            revenue: { type: ["string", "null"] }
        contact:
          type: object
          properties:
            email: { type: ["string", "null"] }
            personal_email: { type: ["string", "null"] }
            phone_e164: { type: ["string", "null"] }
            phone_number: { type: ["string", "null"] }
            phone_type: { type: ["string", "null"] }
            phone_found: { type: boolean }
            phone_searched:
              type: boolean
              description: >-
                Distingue « pas de numéro » de « on n'a pas encore cherché ».
                Deux choses qu'un agent confondrait.
            self_reported_email:
              type: ["string", "null"]
              description: Communiqué par le lead lui-même via un formulaire (texte tiers).
        scoring:
          type: object
          properties:
            lead_score: { type: ["integer", "null"], minimum: 0, maximum: 100 }
            label: { type: ["string", "null"] }
            engagement_score: { type: ["integer", "null"], minimum: 0, maximum: 100 }
            icp_fit_score: { type: ["integer", "null"], minimum: 0, maximum: 100 }
            strengths:
              type: array
              items: { type: string }
            red_flags:
              type: array
              items: { type: string }
            reasoning: { type: ["string", "null"] }
        pipeline:
          type: object
          properties:
            connection_status: { type: ["string", "null"] }
            connection_requested: { type: boolean }
            connection_accepted: { type: boolean }
            connection_requested_at: { type: ["string", "null"], format: date-time }
            connection_accepted_at: { type: ["string", "null"], format: date-time }
            gate_resource_id:
              type: ["string", "null"]
              format: uuid
              description: La ressource proposée derrière le gate.
            resource_sent: { type: boolean }
            resource_sent_at: { type: ["string", "null"], format: date-time }
            resource_opened: { type: boolean }
            resource_opened_at: { type: ["string", "null"], format: date-time }
            form_submitted: { type: boolean }
            form_submitted_at: { type: ["string", "null"], format: date-time }
            meeting_booked: { type: boolean }
            meeting_booked_at: { type: ["string", "null"], format: date-time }
        interaction_count: { type: integer }

    LeadNote:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: lead_note }
        lead_id: { type: string, format: uuid }
        content: { type: string }
        source: { type: ["string", "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: ["string", "null"], format: date-time }

    LeadInteraction:
      type: object
      description: Un événement (lead × post) — commentaire, réaction, livraison, ouverture…
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: lead_interaction }
        lead_id: { type: ["string", "null"], format: uuid }
        post_id: { type: ["string", "null"], format: uuid }
        created_at: { type: string, format: date-time }
        source: { type: ["string", "null"] }
        kind: { type: ["string", "null"] }
        comment_text: { type: ["string", "null"] }
        reaction: { type: ["string", "null"] }
        keyword_matched: { type: boolean }
        engagement_score: { type: ["integer", "null"] }
        pipeline_status: { type: ["string", "null"] }
        resource:
          type: object
          properties:
            sent: { type: boolean }
            sent_at: { type: ["string", "null"], format: date-time }
            opened: { type: boolean }
            opened_at: { type: ["string", "null"], format: date-time }
            clicked_at: { type: ["string", "null"], format: date-time }
            click_count: { type: integer }
        connection:
          type: object
          properties:
            requested_at: { type: ["string", "null"], format: date-time }
            accepted_at: { type: ["string", "null"], format: date-time }
        form_submitted: { type: boolean }
        form_submitted_at: { type: ["string", "null"], format: date-time }
        email_shared: { type: boolean }

    Resource:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: resource }
        post_id: { type: ["string", "null"], format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: ["string", "null"], format: date-time }
        title: { type: ["string", "null"] }
        subtitle: { type: ["string", "null"] }
        slug: { type: ["string", "null"] }
        type: { type: ["string", "null"] }
        language: { type: ["string", "null"] }
        is_published: { type: boolean }
        published_at: { type: ["string", "null"], format: date-time }
        gated: { type: boolean }
        view_count: { type: integer }
        format_version: { type: ["integer", "null"] }
        public_url:
          type: ["string", "null"]
          description: |
            URL publique du lead magnet. Null tant que la page publique ne peut
            pas la servir (ressource non publiée, sans slug, ou d'un format non
            servable) — une URL présente est toujours une page vivante.

    Block:
      type: object
      description: Un bloc de contenu (id opaque, jamais fourni par le client)
      properties:
        id: { type: string }
        type: { type: string }
        content: { type: string }
        metadata: { type: ["object", "null"], additionalProperties: true }

    ResourceBlocks:
      type: object
      properties:
        resource_id: { type: string, format: uuid }
        format_version: { type: ["integer", "null"] }
        updated_at:
          type: ["string", "null"]
          description: Echo du verrou optimiste — à renvoyer en expected_updated_at
        blocks:
          type: array
          items: { $ref: "#/components/schemas/Block" }

    BlockWriteResult:
      type: object
      properties:
        block: { $ref: "#/components/schemas/Block" }
        position: { type: integer, description: "Présent à la création" }
        sanitize_flags:
          type: array
          items: { type: string }
          description: Transformations éditoriales appliquées (jamais un refus)
        updated_at: { type: string, format: date-time }

    ResourceDetail:
      allOf:
        - $ref: "#/components/schemas/Resource"
        - type: object
          properties:
            blocks_count: { type: integer }

    ResourcePreview:
      type: object
      properties:
        object: { type: string, const: resource_preview }
        resource_id: { type: string, format: uuid }
        preview_url:
          type: string
          description: URL de la vraie page /r/ avec token signé — expire, non indexable
        expires_at: { type: string, format: date-time }

    PostVisual:
      type: object
      properties:
        object: { type: string, const: post_visual }
        post_id: { type: string, format: uuid }
        visual_url: { type: string, description: "L'URL Captiv du visuel créé" }
        cover_url: { type: string }
        visual_urls:
          type: array
          items: { type: string }
        mode: { type: string, enum: [append, replace_cover] }

    PostQuestions:
      type: object
      properties:
        object: { type: string, const: post_questions }
        source:
          type: string
          enum: [sur_mesure, banque]
          description: sur_mesure = écrites pour cette idée ; banque = repli déterministe
        questions:
          type: array
          minItems: 3
          maxItems: 3
          items:
            type: object
            properties:
              id: { type: string }
              texte: { type: string }
              justification: { type: string }

    FollowUpIntent:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: follow_up_intent }
        lead_id: { type: string, format: uuid }
        status: { type: string, enum: [queued, planned, sent, cancelled, skipped] }
        message: { type: ["string", "null"] }
        message_source: { type: ["string", "null"], enum: [ia, repli, manuel, null] }
        scheduled_for:
          type: ["string", "null"]
          format: date
          description: Jour PRÉVU. La place réelle est recalculée par l'agent.
        source: { type: ["string", "null"], enum: [ui, api, mcp, null] }
        skip_reason: { type: ["string", "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: ["string", "null"], format: date-time }
        sent_at: { type: ["string", "null"], format: date-time }

    FollowUpQueued:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: follow_up_intent }
        lead_id: { type: string, format: uuid }
        status: { type: string, const: queued }
        message: { type: string }
        message_source: { type: string, enum: [ia, repli, manuel] }
        scheduled_for: { type: ["string", "null"], format: date }
        scheduled_is_estimate:
          type: boolean
          description: true = jour estimé (au-delà du jour dont la place est connue)
        warning:
          type: ["string", "null"]
          description: |
            Ce que le créateur doit savoir sans que ça bloque — par exemple un
            lead pas encore connecté, dont la relance attendra la connexion.

    FollowUpCancelled:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: follow_up_intent }
        status: { type: string, const: cancelled }

    ResourceGenerationJob:
      type: object
      required: [object, status, resource, status_url]
      properties:
        object: { type: string, const: resource_generation }
        status: { type: string, const: running }
        resource:
          allOf:
            - $ref: "#/components/schemas/Resource"
          description: La ressource née en brouillon (is_published toujours false à la naissance)
        status_url: { type: string, description: "À sonder poliment (quelques secondes d'intervalle)" }
        typical_duration_seconds:
          type: array
          items: { type: integer }
          description: Fourchette MESURÉE (jamais une promesse — le status dit les faits)

    ResourceGenerationStatus:
      type: object
      required: [object, resource_id, state]
      properties:
        object: { type: string, const: resource_generation_status }
        resource_id: { type: string, format: uuid }
        state:
          type: string
          enum: [idle, running, completed, failed]
          description: idle = aucun marqueur (créée avec ses blocs, ou antérieure au lifecycle)
        started_at: { type: ["string", "null"], format: date-time }
        finished_at: { type: ["string", "null"], format: date-time }
        blocks_count: { type: integer }
        is_published: { type: boolean }
        updated_at: { type: ["string", "null"] }

    PostGeneration:
      type: object
      required: [object, status]
      properties:
        object: { type: string, const: post_generation }
        status:
          type: string
          enum: [generated, needs_more_input]
          description: needs_more_input = idée trop mince, aucun post créé
        feedback:
          type: ["string", "null"]
          description: Présent quand needs_more_input — quoi enrichir, actionnable
        post:
          description: Le Post créé (+ text et trigger_keyword), null si needs_more_input
          anyOf:
            - allOf:
                - $ref: "#/components/schemas/Post"
                - type: object
                  properties:
                    text: { type: string, description: Le texte LinkedIn complet généré }
                    trigger_keyword: { type: ["string", "null"] }
            - type: "null"
        usage:
          type: object
          description: Coût réel de l'appel, décompté du pool IA du workspace
          properties:
            input_tokens: { type: integer }
            output_tokens: { type: integer }
            web_searches: { type: integer }
            cost_usd: { type: number }

    AnalyticsSummary:
      type: object
      properties:
        object: { type: string, const: analytics_summary }
        period: { type: string, enum: [7d, 30d, 90d] }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        partial:
          type: boolean
          description: >-
            `true` si au moins un compteur n'a pas pu être lu (P614). Les
            champs concernés valent alors `null`, jamais `0` — un zéro et une
            lecture manquée se décident différemment. Une réponse partielle
            n'est pas mémorisée : l'appel suivant recalcule.
        totals:
          type: object
          properties:
            leads_captured: { type: ["integer", "null"] }
            leads_qualified:
              type: ["integer", "null"]
              description: "score_label vip ou qualified"
            posts_published: { type: ["integer", "null"] }
            invitations_sent: { type: integer }
            invitations_attempted: { type: integer }
            messages_sent: { type: integer }
            resources_delivered: { type: integer }
            resource_opens: { type: integer }
            unique_resource_openers: { type: integer }
            relation_checks: { type: integer }
        rates:
          type: object
          properties:
            invitation_success_rate:
              type: integer
              description: "% d'invitations parties sans erreur (0-100)"
            resource_open_rate:
              type: integer
              description: "% de leads livrés ayant ouvert la ressource (0-100)"

    PostDetail:
      allOf:
        - $ref: "#/components/schemas/Post"
        - type: object
          properties:
            linkedin_stats:
              type: object
              description: Stats LinkedIn natives (null tant que non relevées)
              properties:
                impressions: { type: ["integer", "null"] }
                reactions: { type: ["integer", "null"] }
                reposts: { type: ["integer", "null"] }
                followers_gained: { type: ["integer", "null"] }
                profile_viewers: { type: ["integer", "null"] }
                fetched_at: { type: ["string", "null"], format: date-time }

    Post:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: post }
        created_at: { type: string, format: date-time }
        updated_at: { type: ["string", "null"], format: date-time }
        subject: { type: ["string", "null"] }
        status: { type: ["string", "null"] }
        language: { type: ["string", "null"] }
        linkedin_url: { type: ["string", "null"] }
        published_at: { type: ["string", "null"], format: date-time }
        scheduled_publish_at: { type: ["string", "null"], format: date-time }
        is_lead_magnet: { type: boolean }
        is_studio_authored: { type: boolean }
        monitoring:
          type: object
          properties:
            active: { type: boolean }
            started_at: { type: ["string", "null"], format: date-time }
            expires_at: { type: ["string", "null"], format: date-time }
        metrics:
          type: object
          properties:
            leads_count: { type: integer }
            captured_leads_count: { type: integer }
            comment_count: { type: integer }
        resource_id: { type: ["string", "null"], format: uuid }
