ai-engine · Intégration

Changements du contrat plateforme

/v1 a déjà bougé sans le dire, et personne au dehors ne pouvait le savoir : les six écritures de périmètre et PUT /v1/app sont arrivées entre deux livraisons, deux de nos documents affirmaient encore le contraire, et un intégrateur l'a découvert en lisant notre code. Ce fichier existe pour que ça n'arrive plus.

Comment le lire. Chaque réponse du contrat porte X-AI-Engine-Contract, et GET /v1/health rend contract_version. Le majeur est le préfixe d'URL : il vaut 1 tant que les routes s'appellent /v1/…. Le mineur monte à chaque changement, même purement additif — c'est la seule valeur qu'un appelant ait à comparer.

⚠️ signale un changement qui casse un appelant écrit contre la version précédente. Un changement additif n'en porte pas : une route ou une clé de plus ne casse personne.


1.1 — en cours

Les connecteurs, enfin joignables

  • GET /v1/connectors, PUT /v1/connectors/{source}, POST /v1/connectors/sync, GET /v1/connectors/sync/{id}, PUT /v1/connectors/sync/{id}/credential. Additif.

    Le cahier demandait l'import depuis Google Drive / Workspace et Microsoft SharePoint. Le code était écrit, complet, importé par la table des routes — et enregistré nulle part. Les connecteurs n'étaient donc pas branchables par le contrat, et les « 20 à 30 procédures tenant réellement importées » du §31 en dépendaient. Cinq lignes manquaient.

Le socle documentaire d'une app

  • GET /v1/app/documents, PUT|DELETE /v1/app/documents/{name}, GET|POST /v1/app/documents/sources, PATCH|DELETE /v1/app/documents/sources/{id}. Additif.

    Une source déclarée appartenait à une organisation. Un intégrateur qui s'appuie sur une dizaine de documents officiels — France Num, ANSSI, CNIL — devait donc les redéclarer chez chacun de ses clients : mêmes pages relues, redécoupées et réembarquées autant de fois qu'il a de tenants. Le socle se déclare désormais au niveau du produit, et tous ses tenants le lisent sans copie.

    Ces routes exigent la clé d'app : une clé d'organisation reçoit 401. Un tenant lit le socle par ses tours, il ne l'écrit pas.

  • Deux écarts avec les routes d'organisation, et ils se voient dans les statuts. PUT /v1/app/documents/{name} indexe pendant la requête et rend 201 (indexé), 200 (inchangé, ou écarté par l'analyse de périmètre) — jamais 202 ni job_id. POST /v1/app/documents/sources lit la page qu'il déclare, une seule, et rend 201 avec l'issue de la lecture dans detail ; un appelant qui attendait 202 et un passage de fond n'en trouvera pas.

  • Une famille de preuves de plus dans une réponse : un passage du socle. Additif — answer.sources porte les mêmes champs, et la famille n'apparaît pas dans le contrat. Ce qui change pour un appelant qui affiche les sources : un document_id peut désormais désigner un document de l'app et non de l'organisation. Il reste citable et affichable à l'identique.

  • WEB_ALLOWLIST, réglage d'app : les domaines dont une référence web entre en preuve, pour toutes les organisations de l'app. Vide par défaut, donc aucun effet sur une installation existante. Il s'écrit depuis la console — et, depuis cette même version, par PUT /v1/app/settings/WEB_ALLOWLIST (voir plus bas).

Qui est servi — et l'identité, qui n'était pas vérifiée

  • PUT /v1/tenants/{id}/contract, PUT /v1/person et PUT /v1/me/profile — exactement les trois routes que INTEGRATION.md publiait, et qui répondaient route inconnue. Elles existent. Additif : un tenant sans ligne de contrat vaut { active, open }, le comportement d'avant.

    ⚠️ INTEGRATION.md annonçait persons: "enrolled" par défaut. C'est open. La page était fausse dans l'autre sens, et elle est corrigée : un défaut enrolled aurait rendu 402 sur chaque tour de chaque installation existante au premier déploiement. Le prix de ce choix est écrit noir sur blanc — sous open, n'importe quel identifiant présenté est servi, y compris forgé.

  • 402 émis pour de bon, avec tenant_contract et person_subscription, et le reason de la plateforme repris mot pour mot. Le refus arrive avant l'ingress : « un tour refusé ne coûte rien » ne tient que là.

    Ce qu'un 402 ne ferme jamais : PUT /v1/person (sinon l'inscription serait un verrou dont la clé est à l'intérieur), et tout ce qui sert à récupérer ses données ou à les faire supprimer — lire et effacer sa mémoire, lister ses documents personnels (la procédure d'effacement en dépend explicitement), en retirer un, effacer une conversation. Le 402 protège le service rendu, pas les droits d'une personne sur ce qui la concerne.

  • person_identity sur une app : header (défaut) ou signed. Sous signed, la personne vient du sub d'un JWT signé par la plateforme (X-AI-Engine-User-Token), vérifié contre son JWKS — et le tenant du jeton doit être celui de la clé, sinon un jeton valide d'une organisation servirait chez une autre. C'est le code de vérification qui existait déjà dans le moteur et que rien n'appelait.

  • Le cadre rangé d'une personne sert désormais de repli quand un message ne joint pas person. Il n'est lu que dans ce cas : un frontal qui joint person à chaque message ne paie aucun aller-retour de plus.

La consommation

  • GET /v1/usage, GET /v1/usage/runs et GET /v1/app/usage. Additif.

    Le cahier demandait les sources, les jetons et la durée par requête (§26). C'était livré dans la console de l'opérateur, pas dans le contrat : une plateforme devait les reconstituer en gardant la clé trace d'un tour — celle que notre propre documentation lui disait de jeter. La contradiction est levée, et trace n'est plus nécessaire pour connaître un coût.

    Totaux par période depuis usage_events (base de contrôle, donc le coût survit au départ d'un tenant), regroupables par activité, jour, modèle, fournisseur, source ou personne — et par tenant sous clé d'app. Une ligne par tour depuis assistant_runs, paginée par curseur opaque. Ni question, ni réponse, ni trace n'en sortent.

La génération contrainte

  • POST /v1/generate (clé d'app ou de tenant, personne facultative) : une sortie conforme à un JSON Schema que l'appelant fournit. Additif.

    Aucune route ne rendait un objet décrit par l'appelant, et trois appels d'un produit sur quatre ne sont pas des conversations : une veille nocturne, un plan d'action de back-office, l'analyse d'un document au dépôt. Sans cette route, une plateforme garde sa propre clé de fournisseur pour eux — deux fournisseurs, deux journaux de coût.

    Le schéma est borné avant d'être accepté (racine objet, additionalProperties: false et required complet sur chaque objet, maxItems sur chaque tableau, ni $ref ni composition, profondeur ≤ 6, ≤ 200 propriétés, 32 Kio) et chaque refus nomme le nœud. L'appel est facturé sous l'activité generation, avec name en référence, et la conduite de l'app est préfixée à la consigne : ce n'est pas un tunnel vers le fournisseur.

La forme de la réponse

  • answer_form sur POST /v1/apps, PUT /v1/app et le corps d'un tour : steps (défaut) ou recommendations. Additif, et sous steps le prompt envoyé au modèle est identique à l'octet près à celui d'avant — schéma compris.

    Le cahier du POC exige une étape à la fois, ce qui est juste pour du dépannage et faux pour du conseil : une dirigeante qui demande si sa messagerie est protégée décide, elle n'exécute pas. La forme est donc un réglage par tour, et pas deux applications : la même organisation a les deux besoins, souvent dans la même conversation.

  • answer.recommendations (≤ 3) sous cette forme : { title, description, sources }. Une recommandation qui ne cite aucune preuve réelle est écartée par le serveur — « rien sans source » n'a pas d'exception.

  • answer.fact_proposals (≤ 3), dès qu'une fiche est déclarée et que la personne affirme explicitement qu'un fait a changé : { key, label, current_value, proposed_value, reason }. Le moteur n'écrit rien ; label et current_value viennent du serveur, pas du modèle. key est choisie dans une énumération fermée sur la fiche visible, donc une proposition ne peut pas porter sur un fait réservé que la personne n'a pas lu.

La conduite du produit

  • conduct sur POST /v1/apps et PUT /v1/app (4 000 caractères, multi-ligne), et conduct_addendum sur le corps d'un tour (1 000). Additif : vide, l'app retrouve exactement le prompt d'avant, à l'octet près.

    Il n'existait aucun endroit pour poser une consigne de comportement. mission nomme un métier et part dans onze prompts système que prompts-frozen.json fige ; baseline_description décrit un sujet et c'est sur elle que la porte d'entrée et le juge d'ingestion tranchent — y écrire « ne prétends jamais remplacer un audit » faussait le tri des documents de tous les tenants de l'app. conduct n'entre que dans le prompt du composeur, et n'a donc aucun effet de bord : aucun baseline_changed, aucun verdict d'admission invalidé.

    Ce qu'elle ne peut pas faire est écrit dans le prompt, pas seulement dans cette page : elle ne lève jamais « rien sans source », n'élargit jamais le plafond d'action, et ne décide pas de la langue.

  • conduct dans la réponse de GET /v1/app et dans le manifeste d'un paquet d'app.

La fiche de l'organisation

  • GET /v1/tenant/facts et PUT /v1/tenant/facts, et facts accepté aussi par POST /v1/tenants et PUT /v1/tenant. Additif : une organisation sans fiche se comporte exactement comme avant, et un prompt sans fait ne porte aucun bloc de plus.

    Le cadre (profile) ne bougeait pas : cinq chaînes qui situent la demande et dont le composeur dit au modèle qu'elles ne se citent pas. La fiche est l'autre moitié — ce que l'organisation TOURNE —, rendue comme une famille de preuves [O…] qui fait autorité sur son environnement et qui se cite : elle ressort dans answer.sources avec source: "organisation". Un fait ne rend jamais une étape « documentée » pour autant : une step de provenance documentation exige toujours l'extrait d'un vrai document.

  • facts et facts_clearance sur le corps d'un tour. facts l'emporte en bloc sur la fiche rangée, comme person ; facts_clearance (shared par défaut, full) décide si les faits marqués restricted entrent dans le prompt. C'est ce qui permet à une plateforme de montrer la fiche complète à une dirigeante et une fiche réduite à un salarié sans que le moteur ait à connaître ses rôles — il n'a pas d'annuaire.

  • facts_count dans GET /v1/tenant, et non la fiche : elle pèse jusqu'à 20 000 caractères, et cette route est appelée à chaque ouverture d'un écran de réglages.

Statuts

  • 402 payment_required entre au contrat. La personne n'est pas inscrite, ou le contrat de l'organisation est suspendu ou échu. Distinct de 403, qui dit « cette clé n'a pas le droit » là où 402 dit « le droit existe, il n'est pas payé ». Le message reprend le motif que la plateforme a elle-même déclaré.
  • 429 rate_limited entre au contrat, toujours accompagné de Retry-After en secondes.

Une troisième portée pour les documents : le groupe

  • X-AI-Engine-Document-Audience au dépôt, person.groups sur un tour, audience dans GET /v1/documents. Additif : un document sans audience reste lisible par toute l'organisation, donc rien ne change pour ce qui est déjà indexé.

    Le moteur ne connaissait que « toute l'organisation » et « une personne ». Un document réservé à un groupe ne pouvait donc pas être indexé du tout — le pousser comme document d'organisation l'aurait montré à des gens qui n'y ont pas droit.

    Le moteur ne sait pas ce qu'un groupe signifie, et c'est le cœur du choix : il compare des étiquettes opaques, sans annuaire, sans hiérarchie, sans héritage. Le filtre est un prédicat de base appliqué avant la sélection des meilleurs résultats, pas un tri après coup — un tamis post-hoc aurait rendu de moins en moins de résultats à mesure qu'une organisation réserve des documents, et sans que rien ne le dise.

    ⚠️ En-tête absent ≠ en-tête vide : absent garde l'audience déclarée, vide ouvre à toute l'organisation. Sans cette distinction, un redépôt distrait élargirait un document réservé. Changer l'audience ne coûte rien, même sur des octets identiques.

    groups est refusé par PUT /v1/me/profile : une appartenance ouvre des documents réservés, et personne ne se la déclare à soi-même.

Le flux d'événements — la progression, poussée

  • GET /v1/conversations/{cid}/events en text/event-stream. Additif : …/progress ne bouge pas, et un appelant qui sonde n'a rien à changer.

    Rien ne streamait dans ce moteur — pas un text/event-stream, pas un res.write —, et montrer qu'un tour avance coûtait trente relectures de …/progress par tour. Le flux inverse le sens : progress à chaque changement d'étape, end quand plus rien ne tourne, error pour une panne en cours de route.

    Ce qui streame est la PROGRESSION, pas la réponse, et ce n'est pas un premier pas vers le jeton à jeton : la sortie du composeur est un objet validé, l'exact contraire d'un flux. La réponse arrive toujours sur le POST.

    Mêmes refus que …/progress, au même moment — le 402 la ferme aussi. Aucun plafond de débit : l'écoute n'appelle aucun modèle, et le tour observé est déjà compté.

    Deux limites écrites dans API.md plutôt que découvertes : une étape qui vit moins de 500 ms peut n'être jamais vue, et à plusieurs répliques de l'agent le flux peut rester muet — la progression vit en mémoire d'un seul processus.

Le suivi d'un dépôt

  • GET /v1/documents/jobs/{job_id}. Additif.

    Le bloc integration que POST /v1/apps remet à un intégrateur annonçait cette route depuis le premier jour — « attendre indexed : le dépôt rend un job_id, l'indexation est asynchrone » — et elle n'existait sur aucune ligne. Qui suivait la consigne recevait 404 sur le seul chemin qu'on lui avait donné. Le handler interne existait déjà : il ne manquait que la porte.

    Elle ne remplace pas GET /v1/documents, qui dit l'état de la documentation. Celle-ci dit l'état d'un geste, avec une ligne par document (items[]), et elle dit quelque chose que la liste ne peut pas dire : la liste ne relit que les derniers dépôts, donc un job_id rendu par un 202 pouvait en sortir avant d'avoir été lu une seule fois.

Le débit est plafonné

  • 429 rate_limited est désormais ÉMIS, avec Retry-After en secondes, sur le tour et sur POST /v1/generate. Additif : les quatre plafonds valent zéro par défaut, soit « aucun plafond » — une installation qui ne règle rien ne voit aucun changement.

    Le statut, son code et l'en-tête étaient entrés au contrat en 1.1 sans qu'aucune ligne ne les émette. Un tour coûte de l'argent réel et rien n'empêchait une boucle de les enchaîner.

    policy.turns_per_hour et policy.daily_cost_usd sur une app et sur un tenant, sous la règle des autres politiques : on ne peut que durcir, et le refus nomme le plafond de l'installation.

    ⚠️ Deux des trois plafonds sont approximatifs, et c'est écrit dans API.md plutôt que découvert : les seaux de tours vivent dans la mémoire du processus qui sert la requête, donc à deux répliques le débit réel double. Le plafond de coût est exact — il vient du registre, la même source que votre facture. Un refus arrive avant l'ingress : il ne coûte rien et n'entre pas au registre.

Les listes se paginent

  • ⚠️ limit (1 à 500, 100 par défaut) et cursor opaque sur les sept routes de liste : GET /v1/apps, /v1/tenants, /v1/documents, /v1/documents/sources, /v1/me/documents, /v1/conversations, /v1/usage/runs.

    Elles rendaient tout : une organisation avec cinq mille documents recevait cinq mille lignes, et rien ne permettait de les parcourir.

    Le changement casse un appelant écrit avant, et c'est pour cela qu'il porte un ⚠️ : aucune clé de réponse n'a disparu, mais le DÉFAUT TRONQUE là où rien ne tronquait. Qui lisait documents en entier n'en reçoit plus que cent. limit=500 et next_cursor restituent la liste complète.

    next_cursor n'est présent que s'il reste quelque chose : son absence est la fin de la liste. Deux listes ne sont pas paginées, délibérément — les dépôts en cours de GET /v1/documents (bornés par construction, et suivre un dépôt ne doit pas obliger à le chercher page après page) et les orphans de GET /v1/documents/sources (un signal, dont la moitié masquée ne signalerait plus rien).

Retirer un client, retirer un produit

  • DELETE /v1/tenants/{id} (clé d'installation, ou clé d'app pour l'un des siens) et DELETE /v1/apps/{id} (clé d'installation). Additif.

    On savait créer un client par le contrat, pas le retirer. Une plateforme qui a une obligation d'effacement ne pouvait l'honorer qu'en demandant un geste manuel à l'exploitant du moteur — c'est-à-dire en ne l'honorant pas.

    Le retrait d'un tenant emporte l'état Restate de ses objets (il ne vit pas en base : une conversation y garde ce qui s'y est dit et les extraits retenus verbatim), sa base, ses octets (pièces jointes, dépôts, illustrations), ses personnes déclarées et ses réglages. Sa ligne de registre part en dernier : tant qu'elle est là, l'appel se rejoue et reprend par ce qui reste.

    Ce qui survit est nommé dans la réponse (kept) : le registre des coûts — il vit dans la base de contrôle précisément pour que ce qu'un client a consommé survive à son départ, et il ne porte que des montants et des empreintes — et le journal d'audit, y compris la ligne de ce retrait.

    Deux refus protègent : le tenant d'une autre app rend 404 et jamais 403 — le contrat ne dit pas à une app qu'un identifiant existe ailleurs —, et une app qui sert encore une organisation rend 422 en la nommant, parce qu'elle lui enlèverait son catalogue et son socle sans qu'une seule erreur le dise. Un tenant ou une app déclarés dans la configuration de l'installation rendent 422 : le contrat ne défait pas ce que .env affirme.

L'historique, et l'effacement en un geste

  • GET /v1/conversations (clé de tenant) : les conversations que le moteur a réellement tenues, de la plus récente à la plus ancienne, paginées par curseur opaque. Additif.

    Le moteur acceptait n'importe quel cid et y retrouvait son état en silence : un produit qui ne tenait pas sa propre liste avait donc un chat qui marchait et aucun historique, sans une seule erreur pour le lui dire. Il ne pouvait ni répondre à « mes conversations », ni effacer ce qu'il n'énumérait pas. X-AI-Engine-User est un filtre sur cette route, pas une identité : la clé du tenant autorise déjà tout ce qui est à lui.

    { id, user_key, app, created_at, last_turn_at, turns } — et aucun contenu : ni question, ni réponse, ni titre. id est l'identifiant nu, celui des URL de tour ; GET /v1/usage/runs?conversation_id=<id> accepte désormais cette forme nue et rend les tours correspondants.

    L'index commence ici : les conversations tenues avant cette version n'y sont pas. Elles se poursuivent et s'effacent comme avant, elles ne se listent pas.

  • DELETE /v1/me : tout ce que le moteur retient d'une personne, en un appel. 202 { erasure_id, conversations, documents, themes_removed, profile_cleared, memory }. Additif — les routes couche par couche restent servies.

    Notre propre documentation décrivait la procédure en trois étapes, dont une que personne ne pouvait parcourir : « DELETE /v1/conversations/{cid} pour chaque conversation », alors que rien ne les énumérait. Un droit qu'on n'exerce qu'en devinant des identifiants n'est pas un droit.

    L'ordre compte : la mémoire d'abord, parce que sa pause empêche un tour en vol de réenregistrer pendant qu'on vide le reste ; puis chaque conversation par son propre effacement ; puis les documents personnels et leurs thèmes ; puis le cadre rangé. L'abonnement déclaré par PUT /v1/person n'est pas touché — le retirer révoquerait un abonnement que personne n'a résilié, et sous enrolled un effacement de données fermerait le service. Rappeler la route reprend ce qui reste.

    Ce qui survit, par construction : le registre des coûts, qui ne porte que des montants et des empreintes, et le journal d'audit, qui garde la trace du geste (objet person). La trace qu'un droit a été exercé ne s'efface pas avec ce qu'il a fait disparaître.

Conversation

  • ⚠️ POST /v1/conversations/{cid}/messages ne rend plus que { answer, trace, expertise_hint? }. Le corps portait quatre clés de plus que AskResult n'en déclarait : state, illustration_request, refresh_request et scope_refusal. state portait le verbatim de tous les extraits retenus et pesait à lui seul l'essentiel de la réponse ; les trois autres sont des demandes internes que le moteur s'adresse à lui-même. Notre propre documentation disait déjà de ne pas les lire — ce qui était l'aveu qu'elles n'avaient rien à faire là.

    Ce qui ne change pas : l'état de la conversation. Il vit sous la clé Restate <TENANT_ID>:<cid> et c'est lui que le tour suivant relit. Rien à tenir de ce côté-ci.

  • ⚠️ GET /v1/conversations/{cid}/progress rend toujours un objet. Elle rendait le littéral null comme corps entier quand aucun tour ne tournait — c'est-à-dire à la fin de chaque tour : un appelant qui lisait .events sans s'en méfier cassait une fois par tour. Désormais { live: false } à l'arrêt, et { live: true, turn, events } pendant un tour : turn et events sont restés où ils étaient.

Le contrat, lisible par une machine — et un client qu'on installe

  • GET /v1/openapi.json. Additif, sans clé.

    La table ROUTES était la seule liste, et personne au dehors ne pouvait la lire : ni OpenAPI, ni SDK publié. Un intégrateur recopiait à la main le client HTTP et tous les types — et découvrait en 404 les trois routes que notre documentation publiait avant qu'elles existent. Le document est engendré depuis la table, donc il ne peut pas décrire une route qui n'est pas servie, et pnpm typecheck refuse de passer si la copie versionnée (docs/openapi.json) s'en écarte — compteurs de routes de nos quatre documents compris.

    Ce qu'il ne dit pas, et c'est écrit dedans : les champs de la plupart des corps. API.md les tient route par route. Les décrire une seconde fois à la main aurait créé exactement la liste parallèle que ce document existe pour supprimer.

    ⚠️ Deux chemins y portent un nom de segment différent de celui que le moteur journalise — OpenAPI interdit deux chemins de même hiérarchie aux segments nommés autrement. Sans effet sur un appelant ; x-ai-engine-template porte le gabarit exact.

  • @ai-engine/client — le premier paquet construit du dépôt, et le seul qui ne soit pas private. Aucune dépendance d'espace de travail, aucune dépendance du tout : ses types sont engendrés depuis docs/openapi.json, et son transport est le fetch de la plateforme d'exécution. Il pose les en-têtes que l'accès de chaque route exige, refuse avant d'émettre une requête à laquelle il manque une clé, une personne ou un segment de chemin, lève une erreur typée portant code, status, Retry-After et X-AI-Engine-Contract, et suit un next_cursor. Il ne réessaie pas : un client qui réessaierait seul transformerait un plafond de coût en dépense silencieuse. Il ne lit pas non plus un flux — sur …/events, il le refuse en disant quoi faire à la place.

Le paramétrage, par la clé — dix gestes du back-office promus

  • PUT|DELETE /v1/app/settings/{key} et PUT|DELETE /v1/tenant/settings/{key}. Additif.

    Une plateforme exploitée en service géré ne pouvait pas régler son propre produit : chaque réglage sans route devenait un courriel à l'exploitant, par client. C'est le même argument que la consommation, qui l'a déjà emporté — GET /v1/usage existe parce qu'une plateforme ne pouvait pas lire ce qu'elle consomme sans notre console.

    Une paire de routes pour toute clé, et c'est ce qui la rend tenable : ce qui est réglable, dans quelle portée, et avec quelle valeur est une donnée du moteur (settableKeys, scopeRule, settingWriteProblem), pas une liste de routes. Une clé ajoutée au schéma devient réglable sans qu'une route s'ajoute. La cascade reste tenant → app → installation → .env → défaut, et l'effet est au tour suivant.

    Cinq refus en 422, chacun avec sa raison : la clé reste dans .env, elle ne se change pas à chaud, cette portée n'a pas d'effet pour elle, la valeur ne passe pas son schéma — et elle appartient à l'exploitant. Ce dernier refus est nouveau et il est nommé : le modèle et le fournisseur (LLM_*), le plafond de max_output_tokens, les plafonds de débit et de dépense, le plafond d'action, la déclaration des apps et des organisations, l'ancre de l'identité signée. Les raisons étaient déjà publiées ; elles sont désormais appliquées.

    Pas de route de lecture, et c'est un choix : la réponse d'une écriture rend la ligne écrite et sa date, ce qui suffit à vérifier qu'elle a pris. Lire tout l'écran dirait aussi le plancher que l'exploitant pose, qui n'appartient pas à l'app.

  • WEB_ALLOWLIST a donc une route : PUT /v1/app/settings/WEB_ALLOWLIST. La ligne de cette même version qui disait « sans route : il s'écrit depuis la console » n'est plus vraie, et docs/API.md est corrigé avec elle.

  • PUT /v1/app/skills/{id}. Additif, et c'est le geste qui débloque une app neuve : sans catalogue, tout tour répond « aucune compétence de diagnostic », et déposer un document au socle n'y change rien. Le même contrat que la console et qu'un paquet livré — le schéma du thème, ses neuf invariants, le lint de neutralité : cette route ne peut rien écrire qu'un paquet ne pourrait écrire. L'identifiant vient du chemin ; un id qui le contredit est refusé, parce que renommer un thème orphelinerait les documents et les procédures qui le référencent. 201 à la création, 200 à la mise à jour.

  • PUT /v1/app/skills/{id}/web. Additif. Les deux seuls champs d'un thème qui n'entrent pas dans l'index : les changer ne coûte ni embedding ni réécriture. Un corps partiel ne remet rien à zéro. Une demande qui rejoint le catalogue supprime l'écart au lieu de le garder à l'identique (overlay: false).

  • PUT /v1/tenant/skills/{id}/overlay. Additif. Une organisation ne peut que durcir : fermer un thème, relever son risque, imposer l'escalade. Ce n'est pas une politesse de validation — le garde effectif est recalculé à chaque lecture du catalogue, donc une ligne écrite à la main dans la base ne désarmerait rien non plus. Adoucir rend 422. Un écart qui n'écarte plus rien supprime la ligne.

  • POST /v1/tenant/scope-candidates/{id}/decision. Additif. Les sujets qu'un refus de périmètre a rangés — jamais le message de la personne — s'ouvrent, s'écartent et se rétablissent en un appel. Demande SCOPE_CANDIDATES=on, qui se règle maintenant par PUT /v1/tenant/settings/SCOPE_CANDIDATES.

  • PUT /v1/app/procedures/{id} et PUT /v1/app/illustrations/{id}. Additif. Retirer du service une fiche capitalisée ou une image partagée, ou la remettre. Le corps d'une fiche ne s'écrit toujours pas par le contrat : il déciderait de ce que l'assistant dit, pas seulement d'où il cherche.

  • Ce que ces dix routes ne font pas, et les raisons sont inchangées : choisir le modèle ou le fournisseur, lire un secret, écrire le corps d'un thème ou promouvoir une fiche, poser un écart valant pour l'installation entière, lever le plafond d'action de l'installation. Aucune ne prend d'identifiant d'app ou de tenant dans son corps : la clé désigne l'objet, et un corps qui porterait app, tenant ou actor est refusé en 422 plutôt qu'ignoré.

Les deux bornes du §2, et une troncature qui ne disait rien

  • person.groups : 20 → 100 étiquettes. Additif. Une PME de trente personnes avec un groupe par service, par site et par projet dépasse vingt sans rien faire d'anormal. La borne d'avant était une symétrie avec MAX_SCOPES, pas une mesure : groups porte le rôle access, n'entre dans aucun prompt, et cent étiquettes ne coûtent pas un jeton.

  • ⚠️ Une étiquette de groupe doit avoir la FORME d'une audience de document : lettres, chiffres, _, -, ., :, 64 caractères au plus. Une étiquette hors de cette forme — « Agence de Lyon », avec ses espaces et sa majuscule accentuée ailleurs — était acceptée et n'ouvrait jamais rien : l'audience d'un document était déjà bornée ainsi, et les deux côtés sont comparés par égalité de chaîne dans un prédicat SQL. Elle rend maintenant 422. C'est le seul changement de cette version qui puisse refuser un appel qui passait — et il refuse exactement ce qui ne marchait pas.

  • Une troncature silencieuse en moins. Le prédicat d'audience coupait à vingt groupes dans le WHERE : au-delà, une personne perdait les documents de ses groupes 21 et suivants, sans erreur et sans ligne de journal. Un produit qui marche et qui perd des données. La borne est désormais validée à l'entrée — un 422 qui donne le compte — et le prédicat lève plutôt que de tronquer.

  • person.sees_all_groups : un booléen. Additif. Un administrateur d'espace lit les documents de tous les groupes de son organisation sans être membre d'aucun : son droit vient d'un rôle, et un rôle ne se représente ni par une étiquette ni par une hiérarchie. Le déclarer en cent étiquettes aurait obligé une plateforme à tenir la liste de tous ses groupes à jour dans chaque message. Il n'ouvre jamais les documents personnels des autres : ils vivent dans une autre table, filtrée par l'empreinte de leur propriétaire.

  • Profondeur d'un schéma de POST /v1/generate : 6 → 7. Additif. Un niveau de tableau consomme un cran, donc « un outil, ses étapes, et les actions de chaque étape » (racine → implementation → steps[] → actions[] → title) comptait 7 et était refusé — il fallait replier le schéma en deux appels, ce qui double les tours sans rien gagner. C'est notre règle, pas celle d'un fournisseur ; la garde qui tient la dépense reste la taille (32 Kio) et les 200 propriétés.

Les quatre écarts mesurés, et la reprise d'un provisionnement

  • POST /v1/tenants/{id}/provision. Additif, et c'est le manque le plus urgent qu'un intégrateur nous ait signalé : il arrive en production, à l'inscription d'un client. La création provisionne la base dans la requête ; si cette étape échoue, le tenant existe sans base, chaque tour suivant échoue, et il n'existait aucun geste de reprise en libre-service. Rejouable par construction — l'état est dérivé des tables présentes, il n'y a aucune colonne provisioned.

    Deux corrections de prémisse, parce qu'elles comptent : un tenant ne naît pas provisioned: false en général — c'est l'état d'un échec, le chemin ordinaire rend true ; et le moteur réessaie maintenant tout seul une panne passagère de la base. Toute panne de provisionnement était convertie en erreur terminale, ce qui court-circuitait les trois tentatives qui existaient déjà : seule une divergence de schéma l'est désormais.

  • ⚠️ max_output_tokens : la borne basse est 16, pas 1. En dessous, le fournisseur refuse l'appel lui-même et le moteur rendait un 502 opaque après l'aller-retour. C'est un 422 en pré-vol, qui nomme les deux bornes. Un appelant qui demandait moins de 16 recevait déjà une erreur — elle est seulement devenue lisible, et gratuite.

  • Un effort au-dessus de low exige au moins 4 000 jetons de sortie, refusé en 422 avant l'appel. Les jetons de raisonnement sont décomptés de max_output_tokens : à 3 000 en effort medium, la réponse était tronquée avant le JSON et le fournisseur rendait « No object generated » — un 502, payé. Le plancher est le budget que le moteur se donne à lui-même pour une sortie structurée.

  • effort et effort_origin dans la réponse de POST /v1/generate, quand l'effort appliqué n'est pas celui de l'appelant. Additif. La configuration passe devant lui — une préférence de thème, puis LLM_GENERATE_EFFORT de l'app — et l'écrasait en silence.

  • trace.duration_ms disait la durée depuis la dernière REPRISE, pas celle du tour. L'heure de départ était lue hors du journal, donc recalculée à chaque rejeu : un tour de 112 secondes qui s'escalade annonçait 34 ms. Les deux bouts se lisent maintenant dans le journal. Et la même correction vaut pour GET /v1/usage/runs : c'était le même champ, écrit depuis la même trace — aucune route ne rendait la vraie durée.

  • answer.step sous answer_form: "recommendations" : la documentation le présentait comme une exception anecdotique. Il est renseigné dans deux cas qui ne sont pas rares — une demande explicite d'être guidé, et une escalade. Le contrat ne bouge pas (step est null ou un objet dans les deux formes) ; la page le dit maintenant franchement.

Une app neuve n'arrive plus muette, et son socle reçoit un thème

  • POST /v1/apps fait HÉRITER le catalogue, quand l'installation déclare son socle (APP_CATALOGUE_SOURCE, portée installation). Additif, et rien ne change pour une installation qui n'en déclare pas. La réponse porte catalogue_skills — le nombre de thèmes hérités — et catalogue_source.

    Par COPIE, vecteurs compris : zéro embedding. C'est ce qui permet d'hériter sans contredire la règle du dépôt — « créer une app ne coûte jamais un embedding par surprise » : l'installation paie son socle une fois, en semant l'app de référence, et chaque app suivante le reçoit pour le prix d'une requête. Semer le paquet à la création aurait payé 34 embeddings par app.

    Pourquoi ce n'est pas un catalogue câblé : le socle livré est un socle de support informatique. Il convient à une app d'infogérance, pas à une app d'un autre métier, qui hériterait de thèmes dont elle ne veut pas. Laissée vide, la clé n'hérite de rien.

  • catalogue_skills revient dans la réponse, et c'est un revirement qu'il faut dire. Cette même version l'avait RETIRÉ : il valait toujours 0, et un champ qui ne dit qu'une chose se lit mieux dans une phrase (integration.catalogue). Il a maintenant quelque chose à dire — le nombre hérité —, et il revient avec catalogue_source. Un appelant écrit contre la version intermédiaire ne casse pas : integration.catalogue reste une phrase, et les deux clés sont additives.

  • Le bloc integration dit la vérité sur le catalogue. Il annonçait « vide à la création : l'opérateur l'amorce ». Il dit maintenant le nombre hérité, ou, quand il n'y a rien : un catalogue vide fait répondre « aucune compétence de diagnostic » à toute question — et déposer un document au socle n'y change rien. C'est le défaut le plus coûteux du produit, et il ne doit pas s'apprendre au premier tour.

  • ⚠️ Un document du socle d'une app ne reste plus sans thème. Le juge d'indexation ne juge que contre le catalogue existant : sur un catalogue vide il levait, l'erreur était avalée, et le document était indexé avec theme: null — définitivement, sous toute configuration. Un intégrateur l'a mesuré le 1er octobre 2026 : dix documents officiels déposés, un passage retenu, et theme: null dans chaque réponse. Le dépôt joue désormais la couverture du socle, comme la synchronisation d'une organisation le fait depuis l'Étape 1b : le thème du catalogue qui couvre le sujet, ou un thème générique créé pour lui. Ce qui change pour un appelant : theme est renseigné là où il valait null, et PUT /v1/app/documents/{name} coûte un appel de modèle de plus par document non couvert. Une couverture impossible ne fait pas échouer le dépôt.

  • pnpm skills:cover --app=<ID> rattrape les socles déposés avant ce correctif.

Partout

  • X-AI-Engine-Contract sur chaque réponse, erreurs et octets compris, et contract_version dans GET /v1/health.

1.0 — l'état livré, reconstitué

Ce qui existait avant ce fichier. La date est celle de la livraison qui l'a introduit, pas celle où elle a été annoncée — c'est précisément le manque que ce fichier comble.

  • 29/09/2026 — les périmètres s'écrivent par le contrat. POST, PATCH et DELETE sur /v1/app/scopes et /v1/tenant/scopes : six routes, arrivées avec la fusion de recentrage-it, jamais annoncées. docs/API.md titrait encore sa section « Périmètres » en lecture, et docs/FRONTEND.md écrivait « aucune route du contrat ne les écrit ». C'est faux depuis cette date, et c'est une bonne nouvelle : une application tierce peut ouvrir et fermer les périmètres de ses clients elle-même.
  • 29/09/2026 — PUT /v1/app : mission, socle, libellé et politique d'une app, avec baseline_changed calculé par la fonction du juge d'admission.
  • Avant : les 33 routes du contrat plateforme v1, décrites par API.md et parcourues par INTEGRATION.md.

On this page