> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zelinqa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Comprendre le tour précédent et proposer les questions suivantes

> Route principale du runtime. En un seul appel, le moteur résout la question
réellement posée et son outcome, applique les mises à jour du client, réduit
l'état, recalcule les progressions, puis classe les questions éligibles.

Le client n'a pas à savoir quelle candidate son agent a utilisée : si
`decision_id`, `question_id` ou `outcome` sont absents, NBQ les résout depuis
la décision en attente, le texte de l'agent, la réponse structurée et le
contexte. Une reformulation de la question par l'agent hôte reste
rattachable.

Au premier appel d'une session neuve, `previous_turn` est absent.




## OpenAPI

````yaml /openapi.fr.yaml post /v1/sessions/{session_id}/next
openapi: 3.1.0
info:
  title: NBQ Engine — API V1
  version: 1.0.0
  summary: Contrat public du runtime NBQ et de la configuration des bases de questions.
  description: >
    Contrat public de NBQ Engine V1, servi sur `https://api.zelinqa.ai`.


    Ce document est la **source de vérité** pour `nbq-sdk` (Python et
    TypeScript) et

    pour `nbq-mcp`. Les SDK et le serveur MCP sont générés ou alignés depuis ce

    contrat, jamais l'inverse.


    ## Pourquoi V1 succède à 0.9


    La version publique déployée aujourd'hui est la bêta `0.9`, déjà servie sous
    le

    préfixe `/v1`. La V1 **ajoute** ses routes sous le même préfixe sans en
    modifier

    aucune : `POST /v1/next-questions` et `POST
    /v1/sessions/{session_id}/conversion`

    restent fonctionnelles et sont marquées dépréciées. La correspondance
    complète

    est décrite dans `docs/api-compat-0.9-to-v1.md`.


    ## Principes contractuels


    - **Le tenant n'est jamais fourni par le client.** L'authorizer de l'API
    Gateway
      publique résout la clé, puis injecte le tenant, le NBQ et les scopes. Tout
      header `x-tenant-id`, `x-nbq-id` ou `x-scopes` présent dans la requête entrante
      est écrasé.
    - **Toute mutation exige `Idempotency-Key`.** Même clé et même corps
    renvoient la
      réponse d'origine ; même clé et corps différent produisent
      `idempotency_key_reused`.
    - **Toute mutation d'une session existante exige `state_version`**, en
    contrôle
      optimiste.
    - **Les identifiants du tour précédent sont optionnels.** `decision_id`,
      `question_id` et `outcome` aident NBQ ; leur absence n'est pas une erreur, le
      moteur les résout depuis la décision en attente et les messages observés. Une
      valeur explicite du client reste prioritaire sur l'inférence.
    - **Le verbatim est optionnel.** `user_text` peut être omis quand une
    réponse
      structurée ou des mises à jour client suffisent : l'extraction sémantique est
      alors dégradée, signalée par `degraded_reasons`, mais le moteur reste
      fonctionnel.
    - **Aucun interne n'est exposé.** Ni score de sélection, ni preuve
    sémantique
      détaillée, ni embedding, ni prompt, ni prototype de réponse, ni matrice
      compilée n'apparaissent dans une réponse publique.
    - **Le client ne choisit pas la version de configuration.** La session
    épingle
      en interne la version publiée active au moment de sa création.

    ## Arrêts souples


    NBQ ne décide pas à la place de l'appelant. Lorsque `max_turns` est atteint,
    que

    l'objectif est déjà atteint ou que l'éligibilité normale est vide, `/next`

    renvoie **encore la meilleure question disponible** et signale la situation
    dans

    `warnings`. Un `action: stop` n'est produit que lorsqu'aucune question

    identifiable n'existe réellement. Les exclusions dures — question inactive,

    sous-objectif exclu par le client, contrainte stricte de l'appel — ne sont
    jamais

    violées, et toute question retournée porte un `question_id` valide.


    ## Scopes de clé API


    Une clé porte un ou plusieurs scopes. Studio recommande des clés séparées
    pour

    le runtime et pour la gestion, selon le principe du moindre privilège.


    | Scope | Autorise |

    |---|---|

    | `runtime` | les cinq routes de session |

    | `configuration:read` | `GET /v1/configuration` (publiée) et `GET
    /v1/configuration/questions` |

    | `configuration:write` | `POST /v1/configuration/changes` et la lecture du
    brouillon |


    Deux niveaux de contrôle se complètent. L'authorizer décide
    **statiquement**,

    à partir de la méthode et du chemin : c'est lui qui refuse une clé `runtime`

    sur `/v1/configuration/publish`. Le service ajoute un contrôle **dynamique**

    là où le scope dépend du contenu de la requête, que l'authorizer ne voit pas
    :

    aujourd'hui le seul cas est `?state=draft`, qui exige `configuration:write`

    sur une route dont l'authorizer n'exige que `configuration:read`.

    | `configuration:publish` | `POST /v1/configuration/publish` |


    Chaque opération déclare le scope exigé dans `x-required-scopes`. Un scope

    manquant produit `403 insufficient_scope`.


    ## Frontière de confiance


    L'API Configuration est implémentée par le service `bo-api` mais atteignable
    par

    deux portes qui n'ont pas la même confiance :


    - via l'**API Gateway publique** (`6nw77hkcib`, `api.zelinqa.ai`),
    l'authorizer
      Lambda authentifie la clé et **écrase** `x-tenant-id` et `x-scopes` ; ces
      headers font alors autorité ;
    - via l'**API Gateway bo-api** (`jdkpi8qlv5`, `bo-api.zelinqa.ai`),
    l'authentification
      vient des guards NestJS et du JWT Cognito ; **tous** les headers `x-tenant-id`
      et `x-scopes` fournis par l'appelant sont ignorés.

    Le service distingue obligatoirement l'origine via `requestContext.apiId`
    avant

    de construire son contexte d'autorisation. Un JWT valide sur la porte bo-api
    ne

    peut jamais obtenir un scope de publication en forgeant un header `x-*`.

    Ce document ne décrit que la surface publique `api.zelinqa.ai`.
  contact:
    name: Zelinqa
    url: https://docs.zelinqa.ai
  license:
    name: Proprietary — Zelinqa SAS
    identifier: LicenseRef-Zelinqa-Proprietary
  x-contract-status: frozen
  x-jira: NBQ-304
  x-supersedes: openapi/nbq-runtime-v2.openapi.yaml
  x-compatibility-note: docs/api-compat-0.9-to-v1.md
servers:
  - url: https://api.zelinqa.ai
    description: API publique NBQ
security:
  - ApiKeyAuth: []
tags:
  - name: sessions
    description: |
      Cycle de vie d'une conversation NBQ : création, sélection de la question
      suivante, mises à jour hors tour, reprise et retour de résultat.
  - name: configuration
    description: |
      Lecture et modification de la configuration éditoriale d'un NBQ, puis
      publication asynchrone de l'artefact moteur.
paths:
  /v1/sessions/{session_id}/next:
    post:
      tags:
        - sessions
      summary: Comprendre le tour précédent et proposer les questions suivantes
      description: >
        Route principale du runtime. En un seul appel, le moteur résout la
        question

        réellement posée et son outcome, applique les mises à jour du client,
        réduit

        l'état, recalcule les progressions, puis classe les questions éligibles.


        Le client n'a pas à savoir quelle candidate son agent a utilisée : si

        `decision_id`, `question_id` ou `outcome` sont absents, NBQ les résout
        depuis

        la décision en attente, le texte de l'agent, la réponse structurée et le

        contexte. Une reformulation de la question par l'agent hôte reste

        rattachable.


        Au premier appel d'une session neuve, `previous_turn` est absent.
      operationId: nextQuestions
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NextRequest'
            examples:
              premier_tour:
                summary: Premier appel, aucune question encore posée
                value:
                  state_version: 0
              tour_avec_reponse_libre:
                summary: L'agent a reformulé la question ; aucun identifiant fourni
                value:
                  state_version: 3
                  previous_turn:
                    assistant_text: >-
                      Et côté budget, vous vous situez plutôt dans quelle
                      fourchette ?
                    user_text: Autour de 2 000 euros, je ne veux pas dépasser 2 500.
                    message_id: msg_18
              tour_avec_reponse_structuree:
                summary: Réponse à choix, mapping déterministe sans appel LLM
                value:
                  state_version: 4
                  previous_turn:
                    decision_id: dec_7f2a
                    question_id: q_style
                    outcome: asked_answered
                    structured_answer:
                      choice_ids:
                        - choice_contemporain
              rattrapage_de_contexte:
                summary: >-
                  Messages échangés sans passer par NBQ, plus une donnée connue
                  du client
                value:
                  state_version: 5
                  context_update:
                    mode: messages
                    messages:
                      - role: user
                        message_id: msg_21
                        text: >-
                          En fait nous avons deux chats qui montent sur le
                          canapé.
                  client_updates:
                    data:
                      - id: has_pets
                        value: true
              selection_contrainte:
                summary: >-
                  L'agent veut deux questions fermées dans un sous-objectif
                  précis
                value:
                  state_version: 6
                  selection:
                    candidate_count: 2
                    allowed_question_types:
                      - single_choice
                      - multiple_choice
                    sub_objectives:
                      ids:
                        - so_besoin
                      mode: restrict
      responses:
        '200':
          description: Décision calculée, ou arrêt explicite si aucune question n'existe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NextResponse'
              examples:
                decision_normale:
                  $ref: '#/components/examples/DecisionNormale'
                max_turns_atteint:
                  $ref: '#/components/examples/DecisionApresMaxTurns'
                arret_sans_question:
                  $ref: '#/components/examples/ArretSansQuestion'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/UnknownSession'
        '409':
          $ref: '#/components/responses/SessionMutationConflict'
        '410':
          $ref: '#/components/responses/CompiledArtifactUnavailable'
        '422':
          $ref: '#/components/responses/NextUnprocessable'
        '503':
          $ref: '#/components/responses/IdempotencyContention'
components:
  parameters:
    SessionId:
      name: session_id
      in: path
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 128
      description: Identifiant opaque de session attribué par NBQ à la création.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 8
        maxLength: 128
      description: >
        Clé unique par mutation logique, choisie par l'appelant.


        Même clé et même corps renvoient exactement la réponse d'origine, sans

        rejouer l'effet : aucun retry ne double `turn_count`, un outcome, un

        événement, un feedback ou une publication. Même clé avec un corps
        différent

        produit `409 idempotency_key_reused`.


        Les enregistrements sont isolés par tenant et par opération, et sont
        purgés

        après **24 heures**. Passé ce délai, la même clé est traitée comme
        neuve.
  schemas:
    NextRequest:
      type: object
      additionalProperties: false
      required:
        - state_version
      properties:
        state_version:
          type: integer
          minimum: 0
          description: >-
            Version lue par le client avant cette mutation, en contrôle
            optimiste.
        previous_turn:
          $ref: '#/components/schemas/PreviousTurn'
        context_update:
          $ref: '#/components/schemas/ContextUpdate'
        client_updates:
          $ref: '#/components/schemas/ClientUpdates'
        selection:
          $ref: '#/components/schemas/SelectionOptions'
    NextResponse:
      type: object
      additionalProperties: false
      required:
        - request_id
        - session_id
        - decision_id
        - action
        - stop_reason
        - candidates
        - progress
        - turn_count
        - turns_remaining
        - warnings
        - degraded
        - degraded_reasons
        - versions
      description: >
        Aucun score de sélection n'est exposé : le classement est porté par
        `rank`.

        Un score brut n'a pas de sens métier hors du moteur et ne doit pas
        devenir

        une dépendance des intégrations.


        **Invariants garantis par le moteur**, non exprimés en JSON Schema pour

        rester générables en SDK, et couverts par les tests de contrat (NBQ-313)
        :


        - `action: ask` implique `decision_id` non nul, `stop_reason` nul et au
          moins un candidat ;
        - `action: stop` implique `decision_id` nul, `stop_reason` renseigné et
          `candidates` vide.
      properties:
        request_id:
          type: string
        session_id:
          type: string
        decision_id:
          type:
            - string
            - 'null'
          description: >-
            Non nul quand `action` vaut `ask`. À renvoyer dans le tour suivant
            si disponible.
        action:
          type: string
          enum:
            - ask
            - stop
          description: >
            `stop` n'est produit **que** lorsqu'aucune question identifiable

            n'existe. Une limite de tours atteinte, un objectif déjà atteint ou
            une

            éligibilité normale vide ne coupent pas la conversation : le moteur

            propose encore la meilleure question disponible et le signale dans

            `warnings`. La décision d'arrêter appartient à l'appelant.
        stop_reason:
          oneOf:
            - $ref: '#/components/schemas/StopReason'
            - type: 'null'
        candidates:
          type: array
          maxItems: 10
          items:
            $ref: '#/components/schemas/Candidate'
          description: |
            Ordonnées par pertinence décroissante, rang 1 en premier. Lorsque
            plusieurs candidats sont demandés, le moteur simule la couverture
            apportée par le premier avant de choisir le suivant : deux variantes
            demandant la même information ne sont pas retournées ensemble.
        progress:
          $ref: '#/components/schemas/ProgressView'
        turn_count:
          type: integer
          minimum: 0
        turns_remaining:
          type: integer
          minimum: 0
          description: Vaut `0` lorsque la limite souple est atteinte ou dépassée.
        warnings:
          type: array
          uniqueItems: true
          items:
            $ref: '#/components/schemas/SelectionWarning'
        degraded:
          type: boolean
          description: >-
            Vrai lorsque la compréhension du tour a été effectuée en mode
            réduit.
        degraded_reasons:
          type: array
          uniqueItems: true
          items:
            type: string
            enum:
              - missing_user_text
              - summary_only_context
              - semantic_service_unavailable
              - unresolved_previous_turn
        versions:
          $ref: '#/components/schemas/VersionInfo'
    PreviousTurn:
      type: object
      additionalProperties: false
      minProperties: 1
      description: >
        Observation du dernier échange. Absent au premier appel d'une session
        neuve.


        `decision_id`, `question_id` et `outcome` sont des **aides
        optionnelles**,

        jamais des identifiants que le client devrait fabriquer. Lorsqu'ils
        manquent,

        NBQ résout la candidate utilisée depuis la décision en attente,

        `assistant_text`, la réponse structurée et le contexte — ce qui permet à

        l'agent hôte de reformuler librement les questions. Une valeur explicite
        du

        client reste prioritaire sur l'inférence et est validée.
      properties:
        decision_id:
          type: string
          minLength: 1
          maxLength: 128
        question_id:
          type: string
          minLength: 1
          maxLength: 128
        outcome:
          $ref: '#/components/schemas/QuestionOutcome'
        assistant_text:
          type: string
          minLength: 1
          maxLength: 8000
          description: >-
            Question ou message réellement envoyé par l'agent hôte,
            éventuellement reformulé.
        user_text:
          type: string
          minLength: 1
          maxLength: 8000
          description: >
            Verbatim optionnel. Peut être omis lorsque `structured_answer` ou

            `client_updates` suffisent : l'extraction sémantique devient
            dégradée,

            signalée par `degraded_reasons`, mais le moteur reste fonctionnel.
        structured_answer:
          $ref: '#/components/schemas/StructuredAnswer'
        message_id:
          type: string
          minLength: 1
          maxLength: 128
    ContextUpdate:
      oneOf:
        - $ref: '#/components/schemas/ConversationSummary'
        - $ref: '#/components/schemas/ConversationMessageDelta'
      discriminator:
        propertyName: mode
        mapping:
          summary:
            $ref: '#/components/schemas/ConversationSummary'
          messages:
            $ref: '#/components/schemas/ConversationMessageDelta'
      description: >
        Contexte apparu depuis le dernier appel NBQ. Le client choisit un résumé

        compact ou le delta ordonné des messages ; il ne renvoie jamais
        l'historique

        déjà traité.
    ClientUpdates:
      type: object
      additionalProperties: false
      minProperties: 1
      description: >-
        Mises à jour explicites du système appelant, prioritaires sur
        l'inférence.
      properties:
        data:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/DataClientUpdate'
        sub_objectives:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/SubObjectiveOverrideUpdate'
        objective:
          $ref: '#/components/schemas/ObjectiveOverrideUpdate'
    SelectionOptions:
      type: object
      additionalProperties: false
      description: >
        Contraintes valables pour cet appel uniquement. Elles sont appliquées
        **avant**

        le calcul du classement, jamais en filtrant un top 3 déjà constitué.
      properties:
        candidate_count:
          type: integer
          minimum: 1
          maximum: 10
          description: >-
            Remplace pour cet appel le nombre de propositions défini dans la
            configuration.
        sub_objectives:
          $ref: '#/components/schemas/SubObjectiveSelection'
        allowed_question_types:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: '#/components/schemas/QuestionType'
        required_target_ids:
          type: array
          minItems: 1
          maxItems: 100
          uniqueItems: true
          items:
            type: string
            minLength: 1
            maxLength: 128
          description: N'accepter que des questions couvrant au moins une de ces cibles.
        excluded_question_ids:
          type: array
          maxItems: 500
          uniqueItems: true
          items:
            type: string
            minLength: 1
            maxLength: 128
    StopReason:
      type: string
      enum:
        - no_question_available
      description: >
        Unique cause d'arrêt dur : aucune question identifiable ne subsiste
        après

        application des exclusions dures — question inactive, sous-objectif
        exclu par

        le client, contrainte stricte de l'appel. Les autres situations
        terminales

        remontent par `warnings`, la conversation restant décidée par
        l'appelant.
    Candidate:
      type: object
      additionalProperties: false
      required:
        - rank
        - question_id
        - text
        - type
        - choices
        - target_ids
      description: |
        **Invariant garanti par le moteur**, couvert par les tests de contrat :
        `type: open` implique `choices` vide ; tout autre type implique au moins
        deux choix.
      properties:
        rank:
          type: integer
          minimum: 1
          maximum: 10
        question_id:
          type: string
          minLength: 1
          maxLength: 128
          description: >-
            Toujours un identifiant valide du corpus publié, y compris pour un
            repli.
        text:
          type: string
          minLength: 1
          maxLength: 4000
          description: >
            Libellé publié de la question. L'agent hôte reste libre de le
            reformuler :

            NBQ sait rattacher un texte reformulé à sa question au tour suivant.
        type:
          $ref: '#/components/schemas/QuestionType'
        choices:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/CandidateChoice'
          description: Vide pour une question ouverte.
        target_ids:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            minLength: 1
            maxLength: 128
          description: Cibles que cette question adresse, principale en premier.
    ProgressView:
      type: object
      additionalProperties: false
      required:
        - objective
        - sub_objectives
      properties:
        objective:
          $ref: '#/components/schemas/ObjectiveProgress'
        sub_objectives:
          type: array
          items:
            $ref: '#/components/schemas/SubObjectiveProgress'
    SelectionWarning:
      type: string
      enum:
        - max_turns_reached
        - objective_achieved
        - eligibility_exhausted_fallback
        - constraints_relaxed
      description: >
        Conditions d'arrêt réunies, sans que NBQ interrompe la conversation.


        - `max_turns_reached` : la limite souple est atteinte ou dépassée ;

        - `objective_achieved` : les conditions de réussite sont satisfaites ;

        - `eligibility_exhausted_fallback` : plus aucune question n'était
        éligible
          normalement, la proposition est un repli — elle porte tout de même un
          `question_id` valide et respecte les exclusions dures ;
        - `constraints_relaxed` : une préférence `prefer` a dû être élargie.
    VersionInfo:
      type: object
      additionalProperties: false
      required:
        - state_version
        - engine_version
        - api_version
      description: >
        La version de configuration épinglée par la session n'est pas exposée :
        le

        client ne la choisit pas et ne doit pas s'y adosser.
      properties:
        state_version:
          type: integer
          minimum: 0
          description: À renvoyer dans la mutation suivante de cette session.
        engine_version:
          type: string
        api_version:
          type: string
          const: '1.0'
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required:
        - code
        - message
        - request_id
        - details
      description: Enveloppe uniforme de toutes les erreurs métier.
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          minLength: 1
        request_id:
          type: string
          minLength: 1
        details:
          type: object
          additionalProperties: true
    QuestionOutcome:
      type: string
      enum:
        - asked_answered
        - asked_no_answer
        - refused
      description: >
        Résultat d'une question effectivement posée.


        - `asked_answered` : question posée et réponse exploitable reçue ;

        - `asked_no_answer` : question posée, aucune réponse exploitable ;

        - `refused` : refus explicite de répondre.


        Une question jamais posée n'a pas de résultat : elle est simplement
        absente.

        Il n'existe pas de valeur `skipped` — une proposition que l'agent n'a
        pas

        utilisée est journalisée comme décision ignorée, jamais comme un faux

        résultat sur la question.
    StructuredAnswer:
      type: object
      additionalProperties: false
      required:
        - choice_ids
      description: >
        Réponse à une question à choix. Le mapping choix vers information de
        réussite

        est déterministe et compilé à la publication : aucun appel LLM n'est
        effectué

        pour l'interpréter.
      properties:
        choice_ids:
          type: array
          minItems: 1
          maxItems: 50
          uniqueItems: true
          items:
            type: string
            minLength: 1
            maxLength: 128
        free_text:
          type: string
          minLength: 1
          maxLength: 4000
          description: Complément libre, autorisé uniquement pour une question `semi_open`.
    ConversationSummary:
      type: object
      additionalProperties: false
      required:
        - mode
        - text
      properties:
        mode:
          type: string
          const: summary
        text:
          type: string
          minLength: 1
          maxLength: 16000
    ConversationMessageDelta:
      type: object
      additionalProperties: false
      required:
        - mode
        - messages
      properties:
        mode:
          type: string
          const: messages
        messages:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/ConversationMessage'
          description: Messages nouveaux, dans l'ordre conversationnel.
    DataClientUpdate:
      oneOf:
        - $ref: '#/components/schemas/SetDataUpdate'
        - $ref: '#/components/schemas/UnsetDataUpdate'
        - $ref: '#/components/schemas/NotApplicableDataUpdate'
      description: >
        Le client n'envoie jamais de statut. `set` produit une valeur confirmée
        avec

        la priorité de preuve maximale ; `unset` retire la valeur courante ;

        `not_applicable` satisfait une information de réussite sans lui donner
        de

        valeur, par exemple une donnée sans objet pour ce visiteur.
    SubObjectiveOverrideUpdate:
      oneOf:
        - type: object
          additionalProperties: false
          required:
            - id
            - operation
            - status
          properties:
            id:
              type: string
              minLength: 1
              maxLength: 128
            operation:
              type: string
              const: set
            status:
              type: string
              enum:
                - achieved
                - not_achieved
        - type: object
          additionalProperties: false
          required:
            - id
            - operation
          properties:
            id:
              type: string
              minLength: 1
              maxLength: 128
            operation:
              type: string
              const: exclude
        - type: object
          additionalProperties: false
          required:
            - id
            - operation
          properties:
            id:
              type: string
              minLength: 1
              maxLength: 128
            operation:
              type: string
              const: clear
    ObjectiveOverrideUpdate:
      oneOf:
        - type: object
          additionalProperties: false
          required:
            - operation
            - status
          properties:
            operation:
              type: string
              const: set
            status:
              $ref: '#/components/schemas/ObjectiveOverrideValue'
        - type: object
          additionalProperties: false
          required:
            - operation
          properties:
            operation:
              type: string
              const: clear
    SubObjectiveSelection:
      type: object
      additionalProperties: false
      required:
        - ids
        - mode
      properties:
        ids:
          type: array
          minItems: 1
          maxItems: 100
          uniqueItems: true
          items:
            type: string
            minLength: 1
            maxLength: 128
        mode:
          type: string
          enum:
            - restrict
            - prefer
          description: >
            `restrict` interdit strictement les questions hors de ces
            sous-objectifs.

            `prefer` les favorise mais autorise un repli si aucun candidat
            éligible

            n'y reste.
    QuestionType:
      type: string
      enum:
        - open
        - single_choice
        - multiple_choice
        - semi_open
      description: |
        - `open` : réponse libre, aucun choix ;
        - `single_choice` : un seul choix parmi la liste ;
        - `multiple_choice` : plusieurs choix possibles ;
        - `semi_open` : choix proposés, complément libre autorisé via
          `structured_answer.free_text`.

        Le mode de sélection est porté par le type lui-même : aucun champ
        `selection_mode` séparé n'existe en V1.
    CandidateChoice:
      type: object
      additionalProperties: false
      required:
        - choice_id
        - label
      properties:
        choice_id:
          type: string
          minLength: 1
          maxLength: 128
        label:
          type: string
          minLength: 1
          maxLength: 500
    ObjectiveProgress:
      type: object
      additionalProperties: false
      required:
        - computed_status
        - progress
        - client_override
        - effective_status
      properties:
        computed_status:
          $ref: '#/components/schemas/ProgressStatus'
        progress:
          type: number
          minimum: 0
          maximum: 1
          description: >
            Agrégation des sous-objectifs pondérée par leur rôle de complétion.
            Les

            sous-objectifs exclus sortent du dénominateur.
        client_override:
          oneOf:
            - $ref: '#/components/schemas/ObjectiveClientOverrideView'
            - type: 'null'
        effective_status:
          $ref: '#/components/schemas/ProgressStatus'
    SubObjectiveProgress:
      type: object
      additionalProperties: false
      required:
        - id
        - order_position
        - completion_role
        - computed_status
        - progress
        - client_override
        - effective_status
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
        order_position:
          type: integer
          minimum: 0
        completion_role:
          $ref: '#/components/schemas/CompletionRole'
        computed_status:
          $ref: '#/components/schemas/ProgressStatus'
        progress:
          type: number
          minimum: 0
          maximum: 1
        client_override:
          oneOf:
            - $ref: '#/components/schemas/SubObjectiveClientOverrideView'
            - type: 'null'
        effective_status:
          type: string
          enum:
            - not_started
            - in_progress
            - covered
            - blocked
            - excluded
          description: >
            Statut consommable par le client après application de
            `client_override`

            au `computed_status` : sans override, le statut calculé ; `achieved`

            force `covered` ; `not_achieved` conserve le statut calculé sauf que

            `covered` redevient `in_progress` ; `excluded` retire le
            sous-objectif de

            la sélection et des agrégations de l'objectif.
    ErrorCode:
      type: string
      description: Catalogue fermé des erreurs métier V1.
      enum:
        - unauthorized
        - insufficient_scope
        - idempotency_contention
        - state_version_conflict
        - idempotency_key_reused
        - unknown_session
        - invalid_previous_turn
        - constraint_no_match
        - invalid_choice
        - compiled_artifact_unavailable
        - configuration_validation_failed
        - compilation_in_progress
        - unknown_configuration
        - unknown_compilation
    ConversationMessage:
      type: object
      additionalProperties: false
      required:
        - role
      description: |
        Message du delta de contexte. Une `structured_answer` portée par un
        message utilisateur est mappée déterministement, sans LLM, comme pour
        `previous_turn` — elle exige donc `question_id` : sans lui, la requête
        est rejetée en `invalid_previous_turn`. Un `question_id` connu du
        corpus rend les cibles de cette question vérifiables par l'extraction,
        même hors décision en attente.
      properties:
        role:
          type: string
          enum:
            - user
            - assistant
        message_id:
          type: string
          minLength: 1
          maxLength: 128
        question_id:
          type: string
          minLength: 1
          maxLength: 128
        text:
          type: string
          minLength: 1
          maxLength: 8000
        structured_answer:
          $ref: '#/components/schemas/StructuredAnswer'
          description: >
            Réponse structurée rapportée dans le delta. Obligatoirement

            accompagnée de `question_id` (le schéma ne porte pas de if/then :

            la règle est appliquée par le runtime, code
            `invalid_previous_turn`).
        occurred_at:
          type: string
          format: date-time
      anyOf:
        - required:
            - text
        - required:
            - structured_answer
    SetDataUpdate:
      type: object
      additionalProperties: false
      required:
        - id
        - value
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
        operation:
          type: string
          const: set
          default: set
        value:
          description: >
            Valeur validée contre le schéma publié de l'information de réussite.

            Une chaîne, un nombre, un booléen ou une liste de ces valeurs ;
            jamais

            un objet, forme que l'extraction ne saurait produire ni corriger.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: array
              items:
                oneOf:
                  - type: string
                  - type: number
                  - type: boolean
    UnsetDataUpdate:
      type: object
      additionalProperties: false
      required:
        - id
        - operation
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
        operation:
          type: string
          const: unset
    NotApplicableDataUpdate:
      type: object
      additionalProperties: false
      required:
        - id
        - operation
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
        operation:
          type: string
          const: not_applicable
    ObjectiveOverrideValue:
      type: string
      enum:
        - achieved
        - not_achieved
    ProgressStatus:
      type: string
      enum:
        - not_started
        - in_progress
        - covered
        - blocked
    ObjectiveClientOverrideView:
      type: object
      additionalProperties: false
      required:
        - status
      properties:
        status:
          $ref: '#/components/schemas/ObjectiveOverrideValue'
        updated_at:
          type: string
          format: date-time
    CompletionRole:
      type: string
      enum:
        - blocking
        - contributing
        - optional
      description: >
        Rôle d'un sous-objectif dans la réussite de l'objectif.


        - `blocking` : doit être couvert pour conclure normalement ;

        - `contributing` : participe au niveau de qualification exigé ;

        - `optional` : améliore la conversation mais ne bloque jamais la
        réussite.


        Le poids moteur est dérivé du rôle. Le client ne saisit aucun poids
        métier.
    SubObjectiveClientOverrideView:
      type: object
      additionalProperties: false
      required:
        - status
      properties:
        status:
          $ref: '#/components/schemas/SubObjectiveOverrideValue'
        updated_at:
          type: string
          format: date-time
    SubObjectiveOverrideValue:
      type: string
      enum:
        - achieved
        - not_achieved
        - excluded
      description: >
        `achieved` force le sous-objectif à couvert. `not_achieved` empêche une

        complétion calculée prématurée. `excluded` le retire de la sélection et
        des

        dénominateurs de progression de l'objectif.
  examples:
    DecisionNormale:
      summary: Le tour précédent a été compris, deux questions sont proposées
      value:
        request_id: req_1002
        session_id: ses_01J8Z
        decision_id: dec_7f2a
        action: ask
        stop_reason: null
        candidates:
          - rank: 1
            question_id: q_budget
            text: Quel budget souhaitez-vous consacrer au canapé ?
            type: open
            choices: []
            target_ids:
              - annual_budget
          - rank: 2
            question_id: q_delai
            text: À quelle période souhaitez-vous être livré ?
            type: single_choice
            choices:
              - choice_id: choice_1m
                label: Dans le mois
              - choice_id: choice_3m
                label: Dans les trois mois
              - choice_id: choice_later
                label: Plus tard
            target_ids:
              - delivery_window
        progress:
          objective:
            computed_status: in_progress
            progress: 0.34
            client_override: null
            effective_status: in_progress
          sub_objectives:
            - id: so_besoin
              order_position: 0
              completion_role: blocking
              computed_status: covered
              progress: 1
              client_override: null
              effective_status: covered
            - id: so_budget
              order_position: 1
              completion_role: blocking
              computed_status: in_progress
              progress: 0.15
              client_override: null
              effective_status: in_progress
            - id: so_livraison
              order_position: 2
              completion_role: contributing
              computed_status: not_started
              progress: 0
              client_override: null
              effective_status: not_started
        turn_count: 2
        turns_remaining: 8
        warnings: []
        degraded: false
        degraded_reasons: []
        versions:
          state_version: 4
          engine_version: 1.0.0
          api_version: '1.0'
    DecisionApresMaxTurns:
      summary: La limite de tours est dépassée ; NBQ propose encore, l'appelant décide
      value:
        request_id: req_1003
        session_id: ses_01J8Z
        decision_id: dec_8c11
        action: ask
        stop_reason: null
        candidates:
          - rank: 1
            question_id: q_delai
            text: À quelle période souhaitez-vous être livré ?
            type: single_choice
            choices:
              - choice_id: choice_1m
                label: Dans le mois
              - choice_id: choice_3m
                label: Dans les trois mois
              - choice_id: choice_later
                label: Plus tard
            target_ids:
              - delivery_window
        progress:
          objective:
            computed_status: in_progress
            progress: 0.86
            client_override: null
            effective_status: in_progress
          sub_objectives:
            - id: so_besoin
              order_position: 0
              completion_role: blocking
              computed_status: covered
              progress: 1
              client_override: null
              effective_status: covered
            - id: so_budget
              order_position: 1
              completion_role: blocking
              computed_status: covered
              progress: 1
              client_override: null
              effective_status: covered
            - id: so_livraison
              order_position: 2
              completion_role: contributing
              computed_status: in_progress
              progress: 0.4
              client_override: null
              effective_status: in_progress
        turn_count: 10
        turns_remaining: 0
        warnings:
          - max_turns_reached
        degraded: false
        degraded_reasons: []
        versions:
          state_version: 21
          engine_version: 1.0.0
          api_version: '1.0'
    ArretSansQuestion:
      summary: Aucune question identifiable ne subsiste après les exclusions dures
      value:
        request_id: req_1004
        session_id: ses_01J8Z
        decision_id: null
        action: stop
        stop_reason: no_question_available
        candidates: []
        progress:
          objective:
            computed_status: covered
            progress: 1
            client_override: null
            effective_status: covered
          sub_objectives:
            - id: so_besoin
              order_position: 0
              completion_role: blocking
              computed_status: covered
              progress: 1
              client_override: null
              effective_status: covered
            - id: so_budget
              order_position: 1
              completion_role: blocking
              computed_status: covered
              progress: 1
              client_override: null
              effective_status: covered
            - id: so_livraison
              order_position: 2
              completion_role: contributing
              computed_status: not_started
              progress: 0
              client_override:
                status: excluded
                updated_at: '2026-09-01T09:31:00Z'
              effective_status: excluded
        turn_count: 12
        turns_remaining: 0
        warnings:
          - objective_achieved
          - max_turns_reached
        degraded: false
        degraded_reasons: []
        versions:
          state_version: 25
          engine_version: 1.0.0
          api_version: '1.0'
  responses:
    Unauthorized:
      description: >
        Clé absente, malformée, inconnue, révoquée, expirée, ou rattachée à un
        NBQ

        suspendu. Le refus vient de l'authorizer de la passerelle, avant que la

        requête n'atteigne le service : le corps est donc produit par la
        passerelle

        et peut ne pas suivre l'enveloppe d'erreur métier.


        La distinction avec `403` est nette : `401` signifie « je ne sais pas
        qui

        vous êtes », `403` signifie « je sais qui vous êtes, mais cette clé ne
        porte

        pas le scope requis ».
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            code: unauthorized
            message: Clé d'intégration absente ou invalide.
            request_id: req_9000
            details: {}
    InsufficientScope:
      description: >
        La clé est valide mais ne porte pas le scope exigé par l'opération. Une
        clé

        absente ou invalide produit `401` au niveau de la passerelle, avant
        d'atteindre

        le service.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            code: insufficient_scope
            message: Cette clé ne permet pas de publier une configuration.
            request_id: req_9001
            details:
              required_scopes:
                - configuration:publish
              granted_scopes:
                - configuration:read
                - configuration:write
    UnknownSession:
      description: Session inconnue, ou appartenant à un autre tenant.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            code: unknown_session
            message: La session demandée est inconnue.
            request_id: req_9007
            details:
              session_id: ses_inconnue
    SessionMutationConflict:
      description: Conflit de version optimiste ou réutilisation d'une clé d'idempotence.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            state_version_conflict:
              summary: La session a été modifiée entre-temps
              value:
                code: state_version_conflict
                message: La session a été modifiée depuis votre dernière lecture.
                request_id: req_9003
                details:
                  supplied_state_version: 7
                  current_state_version: 8
            idempotency_key_reused:
              summary: Même clé, corps différent
              value:
                code: idempotency_key_reused
                message: Cette clé d'idempotence est déjà associée à une autre requête.
                request_id: req_9004
                details:
                  idempotency_key: next-ses01J8Z-turn-4
    CompiledArtifactUnavailable:
      description: >
        L'artefact compilé épinglé par la session est réellement inaccessible.
        Une

        configuration simplement remplacée par une version plus récente ne
        produit pas

        cette erreur : l'ancienne version reste lisible pour terminer les
        sessions qui

        l'utilisent déjà.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            code: compiled_artifact_unavailable
            message: >-
              L'artefact moteur de cette session est temporairement
              indisponible.
            request_id: req_9010
            details:
              session_id: ses_01J8Z
    NextUnprocessable:
      description: >
        Le tour précédent est incohérent, ou les contraintes de l'appel ne
        laissent

        aucune question possible.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            invalid_previous_turn:
              summary: Les identifiants fournis contredisent la décision en attente
              value:
                code: invalid_previous_turn
                message: >-
                  Les identifiants fournis ne correspondent pas à la décision en
                  attente.
                request_id: req_9011
                details:
                  pending_decision_id: dec_7f2a
                  supplied_decision_id: dec_7f29
                  supplied_question_id: q_budget
                  candidate_question_ids:
                    - q_style
                    - q_budget
                    - q_delai
            constraint_no_match:
              summary: Aucune question ne satisfait les contraintes strictes demandées
              value:
                code: constraint_no_match
                message: >-
                  Aucune question disponible ne satisfait les contraintes
                  demandées.
                request_id: req_9012
                details:
                  allowed_question_types:
                    - multiple_choice
                  required_target_ids:
                    - annual_budget
            invalid_choice:
              summary: Un choix n'appartient pas à la question résolue
              value:
                code: invalid_choice
                message: Un choix fourni ne correspond pas à la question résolue.
                request_id: req_9013
                details:
                  question_id: q_style
                  invalid_choice_ids:
                    - choice_inconnu
    IdempotencyContention:
      description: >
        La clé d'idempotence n'a pas pu être réservée après deux tentatives, à
        cause

        d'un cycle anormal de requêtes concurrentes portant la même clé.


        Ce n'est **pas** une erreur du client et ce n'est pas un conflit :
        réessayer

        après un court délai a des chances d'aboutir. À distinguer de

        `idempotency_key_reused`, qui est définitif pour ce couple clé/corps.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            code: idempotency_contention
            message: La clé d'idempotence n'a pas pu être réservée, réessayez.
            request_id: req_9017
            details:
              idempotency_key: next-ses01J8Z-turn-4
              retry_after_seconds: 1
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: NBQ API key
      description: >
        Clé d'intégration préfixée `nbq_live_`, envoyée dans

        `Authorization: Bearer nbq_live_…` et résolue par l'authorizer Lambda de

        l'API Gateway publique. L'authorizer valide la clé, puis **injecte** en
        amont

        du service les headers de contexte `x-tenant-id`, `x-nbq-id` et
        `x-scopes` —

        `x-scopes` étant la liste des scopes de la clé séparés par des
        **virgules**,

        par exemple `runtime,configuration:read`.


        Ces headers ne sont jamais acceptés depuis le client : toute valeur
        entrante

        est écrasée. Une clé absente ou invalide produit `401` au niveau de la

        passerelle ; une clé valide sans le scope requis produit `403` avec le
        code

        `insufficient_scope`.

````