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
Un tenant d'essai se déclare comme tel
-
is_testsurPOST /v1/tenants. Additif, pas de bump — un champ optionnel de plus ne casse personne, et omis il vautfalse, c'est-à-dire ce que toute création signifiait jusqu'ici.Les cinq tenants de test d'une installation s'appellent comme des clients, et aucun préfixe ne les distinguait : une convention de nommage aurait été un contrat implicite que rien ne fait respecter. Le drapeau n'écarte que des listes de la console d'exploitation — aucun tour, aucune ingestion et aucun coût ne se comportent différemment. Il se pose aussi sur un tenant existant, depuis la console, sans quoi la colonne ne servirait à rien pour ceux qui sont nés avant elle.
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 nijob_id.POST /v1/app/documents/sourceslit la page qu'il déclare, une seule, et rend 201 avec l'issue de la lecture dansdetail; 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.sourcesporte les mêmes champs, et la famille n'apparaît pas dans le contrat. Ce qui change pour un appelant qui affiche les sources : undocument_idpeut 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, parPUT /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/personetPUT /v1/me/profile— exactement les trois routes queINTEGRATION.mdpubliait, et qui répondaientroute inconnue. Elles existent. Additif : un tenant sans ligne de contrat vaut{ active, open }, le comportement d'avant.⚠️
INTEGRATION.mdannonçaitpersons: "enrolled"par défaut. C'estopen. La page était fausse dans l'autre sens, et elle est corrigée : un défautenrolledaurait rendu 402 sur chaque tour de chaque installation existante au premier déploiement. Le prix de ce choix est écrit noir sur blanc — sousopen, n'importe quel identifiant présenté est servi, y compris forgé. -
402 émis pour de bon, avec
tenant_contractetperson_subscription, et lereasonde 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_identitysur une app :header(défaut) ousigned. Soussigned, la personne vient dusubd'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 jointpersonà chaque message ne paie aucun aller-retour de plus.
La consommation
-
GET /v1/usage,GET /v1/usage/runsetGET /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é
traced'un tour — celle que notre propre documentation lui disait de jeter. La contradiction est levée, ettracen'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 depuisassistant_runs, paginée par curseur opaque. Ni question, ni réponse, ni trace n'en sortent. -
group_byaccepte plusieurs dimensions, et les paramètres entrent au contrat. Additif. Sur une seule dimension, la réponse est identique à celle d'avant.group_by=tenant_id,dayrend d'un seul appel la matrice qu'une facturation lit. Un front qui sert plusieurs clients devait jusqu'ici relever une fenêtre par client, parce que chacun a sa propre date de souscription. Une dimension répétée est refusée — elle laisserait croire à une seconde coupe — et 10 000 lignes rendues au plus : c'est le produit cartésien qui coûte, pas la façon de le demander. Refusé, jamais tronqué en silence.from,to,group_byetactivitysont enfin publiés au document OpenAPI, avec le schéma detotals. Ils n'existaient que dansAPI.md: un intégrateur qui lisait le contrat ne pouvait pas découvrir qu'une période se choisit. La borne haute y est dite exclue, doncfrom=2026-09-10&to=2026-10-10est exactement un cycle et deux cycles consécutifs se posent bord à bord.Et la phrase de
GET /v1/app/usagedisait le contraire du code : « pour son propre compte, hors clients ». Le filtre estapp_id, et l'attribution d'un tour porte l'app du tenant servi : la route couvre l'app et tous ses clients,group_by=tenant_iden donnant la répartition. C'était la seule route qui répondait au besoin, et sa phrase l'en écartait.
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: falseetrequiredcomplet sur chaque objet,maxItemssur chaque tableau, ni$refni composition, profondeur ≤ 6, ≤ 200 propriétés, 32 Kio) et chaque refus nomme le nœud. L'appel est facturé sous l'activitégeneration, avecnameen 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_formsurPOST /v1/apps,PUT /v1/appet le corps d'un tour :steps(défaut) ourecommendations. Additif, et sousstepsle 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 ;labeletcurrent_valueviennent du serveur, pas du modèle.keyest 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.
Le tour dit ce qui l'a conduit
-
topicdans le contrat d'un thème —PUT /v1/app/skills/{id}, le manifeste d'un paquet, et la réponse deGET /v1/app/skills. Additif : une clé facultative de plus (64 caractères), et un thème qui ne la pose pas se comporte exactement comme avant.C'est l'étiquette de la plateforme — « IT & usages », « Stratégie DSI » —, celle que le produit affiche à côté d'une réponse. Une déclaration, jamais une mesure : le moteur ne la lit pour router, ne la compare à rien et ne l'indexe pas. Elle reste donc hors de
search_text, et la poser sur les 35 fiches du catalogue n'a coûté aucun embedding —index_versionet le vecteur ne bougent pas. -
answer.topic,answer.leveletanswer.themesdans la réponse d'un tour. Additif : trois clés de plus, et toujours présentes — un champ parfois absent est exactement ce qui force un appelant à écrire une branche de repli, donc à décider du défaut à la place du moteur.Jusqu'ici, pour afficher son bandeau, une plateforme devait déduire : découper
trace.selected_skillset dé-slugifier le suffixe, chercher l'identifiant danstrace.skill_candidates[].name— qui ne le contient pas toujours, car une ancre, une escalade forcée ou un repli de routage retiennent un thème qui n'était pas candidat —, ou tenir une table codée en dur. Et latraceest déclarée interne (docs/FRONTEND.md). Les trois clés suppriment la déduction.levelvaut six clés stables et bas de casse, que le frontal traduit :action·conseil·investigation·question·demande·escalade. Elles aplatissent trois cas que le moteur distingue encore — le triage tombe dansquestion, la lacune documentaire et le hors-périmètre dansaction—, et c'estthemesqui les sépare : une lacune portethemes[0].id === "resolution.knowledge.gap", un hors-périmètre rendthemesvide. Leleveldit le registre,themesdit le cas. -
answer.sources[].theme. Additif, et facultatif par nature : la clé est absente, jamaisnull, pour une famille qui n'a pas de thème — une référence web, un résultat d'outil, un fait de la fiche, une pièce jointe.documents.themeetapp_documents.themeétant NULLABLE, un document interne sans thème se lit comme une source web, par la seule absence de la clé.L'identifiant seul : le nom, le domaine et le topic sont dans
answer.themes, une fois. Même discipline queanswer.recommendations[].sources, et pour la même raison — trois copies du même thème dans un corps qu'on vient d'alléger de 97 % seraient une régression. Rien n'est ajouté àtrace. -
⚠️ La compatibilité des paquets d'app devient asymétrique.
SkillSchemaest.strict(): un paquet exporté par un moteur qui connaîttopicest refusé à l'import par un moteur déployé avant cette version — la fiche porte une clé que son schéma n'admet pas. L'inverse marche sans rien faire : un paquet d'avant s'importe tel quel, les thèmes arrivent sans topic.Il n'y a rien à faire tant que personne ne pose de topic ; dès qu'un seul en porte un, le paquet ne redescend plus vers une installation plus ancienne.
La conduite du produit
-
conductsurPOST /v1/appsetPUT /v1/app(4 000 caractères, multi-ligne), etconduct_addendumsur 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.
missionnomme un métier et part dans onze prompts système queprompts-frozen.jsonfige ;baseline_descriptiondé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.conductn'entre que dans le prompt du composeur, et n'a donc aucun effet de bord : aucunbaseline_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.
-
conductdans la réponse deGET /v1/appet dans le manifeste d'un paquet d'app.
La fiche de l'organisation
-
GET /v1/tenant/factsetPUT /v1/tenant/facts, etfactsaccepté aussi parPOST /v1/tenantsetPUT /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 dansanswer.sourcesavecsource: "organisation". Un fait ne rend jamais une étape « documentée » pour autant : unestepde provenancedocumentationexige toujours l'extrait d'un vrai document. -
factsetfacts_clearancesur le corps d'un tour.factsl'emporte en bloc sur la fiche rangée, commeperson;facts_clearance(sharedpar défaut,full) décide si les faits marquésrestrictedentrent 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_countdansGET /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_requiredentre 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é ». Lemessagereprend le motif que la plateforme a elle-même déclaré. - 429
rate_limitedentre au contrat, toujours accompagné deRetry-Afteren secondes.
Une troisième portée pour les documents : le groupe
-
X-AI-Engine-Document-Audienceau dépôt,person.groupssur un tour,audiencedansGET /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.
groupsetsees_all_groupssont refusés parPUT /v1/me/profile: une appartenance ouvre des documents réservés, et personne ne se la déclare à soi-même. ⚠️sees_all_groupsy passait jusqu'au 19/10/2026 — un appelant qui l'y envoyait reçoit maintenant 422, et un profil déjà rangé est relu sans ses deux champs d'accès. Il est strictement plus puissant quegroups: il lève le prédicat au lieu de l'élargir.
Le flux d'événements — la progression, poussée
-
GET /v1/conversations/{cid}/eventsentext/event-stream. Additif :…/progressne bouge pas, et un appelant qui sonde n'a rien à changer.Rien ne streamait dans ce moteur — pas un
text/event-stream, pas unres.write—, et montrer qu'un tour avance coûtait trente relectures de…/progresspar tour. Le flux inverse le sens :progressà chaque changement d'étape,endquand plus rien ne tourne,errorpour 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.mdplutô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
integrationquePOST /v1/appsremet à un intégrateur annonçait cette route depuis le premier jour — « attendre indexed : le dépôt rend unjob_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 unjob_idrendu par un 202 pouvait en sortir avant d'avoir été lu une seule fois.
Un document ingéré dit ce qu'il contient
-
summarysur un document —GET /v1/documents,PUT /v1/app/documents/{name},PUT|GET /v1/me/documents, etitems[].summarydu compte rendu d'un dépôt. Additif.L'ingestion n'appelait un modèle que pour classer : juge de périmètre, juge de thème, description des images. Rien ne disait ce qu'un document contenait, et le retour d'un dépôt portait les octets reçus, pas ce qui en avait été fait. Un document indexé porte désormais, en deux à quatre phrases, ce qu'il dit — écrit à l'indexation, dans l'aller-retour d'embedding qui est déjà le chemin critique du document, donc sans latence en plus.
Vide dans trois cas, et ce ne sont pas des oublis : un document écarté par l'analyse de périmètre, qui n'est pas écrit ; un document inchangé du socle d'app, qui n'est pas résumé une seconde fois — sa synthèse est dans la liste ; et un modèle qui n'a pas répondu, car une synthèse manquante ne coûte jamais l'indexation d'un document.
DOCUMENT_SUMMARY=offla coupe pour toute une installation ou pour un seul tenant.Le coût du premier passage après la livraison, à dire sans détour : un appel en effort bas par document déjà en base sans synthèse, soit de l'ordre de 70 par tenant, payé une fois. Le rattrapage lit les premiers passages déjà écrits — ni parsing, ni nouveau vecteur —, et aucun document n'est réindexé : la synthèse n'entre dans aucun vecteur, donc la version d'index ne bouge pas.
Le compte rendu d'un dépôt est rendu, et la cadence de l'attente est dite
-
items_unthemed,chunks_writtenetcover_logsurGET /v1/documents/jobs/{job_id},theme_originetsummarysur sesitems[],Retry-Aftersur les 202 de la file. Additif.Le dépôt d'un document d'organisation était le seul des quatre chemins à ne rien dire de ce qu'il avait produit : le 202 rend les octets reçus, et quatre champs du compte rendu interne restaient hors contrat. Trois y entrent — notre propre console les affichait déjà.
chunks_writtenest le total du geste, qu'un appelant déposant vingt documents n'a plus à sommer ailleurs ;items_unthemedest le seul résultat qu'une étapeindexedne laisse pas devenir — un document sans thème n'est porté par aucun skill, donc jamais cité ;cover_logentre borné aux quarante dernières lignes, chacune à 300 caractères, et c'est la borne qui manquait, pas l'utilité.actorreste dehors : la passerelle le remplit toujours avecplatform.Une boucle d'attente était la seule façon de finir un dépôt, et rien ne disait à quelle cadence : les trois 202 de la file et la lecture d'un dépôt non terminé portent désormais
Retry-Afteren secondes. Un dépôt fini ne le porte pas. Toujours pas de?wait=: il ferait du plafond de requête du moteur un plafond de produit.
Le débit est plafonné
-
429
rate_limitedest désormais ÉMIS, avecRetry-Afteren secondes, sur le tour et surPOST /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_houretpolicy.daily_cost_usdsur 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.mdplutô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) etcursoropaque 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
documentsen entier n'en reçoit plus que cent.limit=500etnext_cursorrestituent la liste complète.next_cursorn'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 deGET /v1/documents(bornés par construction, et suivre un dépôt ne doit pas obliger à le chercher page après page) et lesorphansdeGET /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) etDELETE /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
.envaffirme.
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
cidet 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-Userest 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.idest 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/personn'est pas touché — le retirer révoquerait un abonnement que personne n'a résilié, et sousenrolledun 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}/messagesne rend plus que{ answer, trace, expertise_hint?, expertise_inert? }. Le corps portait quatre clés de plus queAskResultn'en déclarait :state,illustration_request,refresh_requestetscope_refusal.stateportait 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. -
expertise_inertdans la réponse d'un tour. Additif : une clé facultative de plus, présente aux seuls tours qui ont une entréeexpertisedésignant un périmètre inconnu.expertise[].scope_idest comparé à l'égalité exacte, et rien ne le confrontait aux périmètres réellement ouverts : une faute de frappe, ou un périmètre fermé depuis, restait inerte à vie — le niveau retombait au défaut, et pas une ligne ne le disait. Le tour n'échoue pas pour autant (un niveau est une forme, pas un droit) et le moteur ne devine aucun rapprochement : il rend{ scope_ids, open_scope_ids }, et c'est la plateforme qui corrige son référentiel. -
⚠️
GET /v1/conversations/{cid}/progressrend toujours un objet. Elle rendait le littéralnullcomme corps entier quand aucun tour ne tournait — c'est-à-dire à la fin de chaque tour : un appelant qui lisait.eventssans s'en méfier cassait une fois par tour. Désormais{ live: false }à l'arrêt, et{ live: true, turn, events }pendant un tour :turneteventssont 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, etpnpm typecheckrefuse 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.mdles 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-templateporte le gabarit exact. -
@ai-engine/client— le premier paquet construit du dépôt, et le seul qui ne soit pasprivate. Aucune dépendance d'espace de travail, aucune dépendance du tout : ses types sont engendrés depuisdocs/openapi.json, et son transport est lefetchde 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 portantcode,status,Retry-AfteretX-AI-Engine-Contract, et suit unnext_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}etPUT|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/usageexiste 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 restetenant → 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 demax_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_ALLOWLISTa 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, etdocs/API.mdest 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 ; unidqui 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. DemandeSCOPE_CANDIDATES=on, qui se règle maintenant parPUT /v1/tenant/settings/SCOPE_CANDIDATES. -
PUT /v1/app/procedures/{id}etPUT /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,tenantouactorest 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 avecMAX_SCOPES, pas une mesure :groupsporte le rôleaccess, 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 colonneprovisioned.Deux corrections de prémisse, parce qu'elles comptent : un tenant ne naît pas
provisioned: falseen général — c'est l'état d'un échec, le chemin ordinaire rendtrue; 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
lowexige au moins 4 000 jetons de sortie, refusé en 422 avant l'appel. Les jetons de raisonnement sont décomptés demax_output_tokens: à 3 000 en effortmedium, 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. -
efforteteffort_origindans la réponse dePOST /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, puisLLM_GENERATE_EFFORTde l'app — et l'écrasait en silence. -
trace.duration_msdisait 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 pourGET /v1/usage/runs: c'était le même champ, écrit depuis la même trace — aucune route ne rendait la vraie durée. -
answer.stepsousanswer_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 (stepestnullou 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/appsfait 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 portecatalogue_skills— le nombre de thèmes hérités — etcatalogue_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_skillsrevient dans la réponse, et c'est un revirement qu'il faut dire. Cette même version l'avait RETIRÉ : il valait toujours0, 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 aveccatalogue_source. Un appelant écrit contre la version intermédiaire ne casse pas :integration.cataloguereste une phrase, et les deux clés sont additives. -
Le bloc
integrationdit 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, ettheme: nulldans 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 :themeest renseigné là où il valaitnull, etPUT /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.
GET /v1/health dit maintenant lequel des deux socles manque
-
control_db, un booléen de plus. Additif : les quatre champs précédents ne changent ni de nom, ni de sens, ni de valeur, et un appelant qui ne lit querestaten'a rien à faire.Ce qu'il dit : la base de CONTRÔLE existe-t-elle, avec ses six tables de registres. Fausse, l'installation n'a jamais été provisionnée — elle ne connaît alors ni app, ni organisation, ni personne, et répond à tout sans rien savoir. C'était exactement l'état d'un déploiement du 8 octobre 2026 : pods
Ready, health à 200, registres vides, et aucune ligne de journal pour le dire.Pourquoi dans le contrat et pas seulement dans une sonde interne : c'est la seule question que la route posait à moitié. Elle répondait déjà 200 quand Restate était mort —
restateexiste pour ça —, et elle répondait 200 de la même façon quand la base de contrôle manquait, sans aucun champ pour le distinguer. Un intégrateur qui vérifiaitstatus: "ok"croyait avoir une plateforme ; il avait un serveur.La disponibilité annoncée par le cluster exige désormais les DEUX : la readiness du conteneur de la passerelle lit
restate && control_db, donc une installation sans base de contrôle n'a aucun endpoint et son Ingress rend 503. Pour un appelant, c'est un changement de ce qu'il OBSERVE, pas du contrat : il ne recevait de toute façon rien d'utile de cette installation-là.Le champ est mémorisé 15 secondes côté moteur : la route est publique, et sans mémo n'importe qui en ferait un générateur de requêtes sur la base de contrôle.
Une étape de progression nomme le stade, plus son contenu
-
detaildisparaît de l'étape deGET /v1/conversations/{cid}/progresset deGET …/events. Pas de bump : le champ était facultatif, déjà absent de la plupart des étapes, etopenapi.jsonne porte aucun schéma de corps pour ces deux routes. Un appelant qui le lisait sans vérifier le lisait déjàundefinedla plupart du temps. Il est retiré plutôt que laissé : documenté et jamais émis, il aurait été une promesse morte.Les libellés deviennent sobres. Ils énuméraient le contenu du tour — la demande recopiée, la phrase d'intention du modèle, les titres des documents retenus (« 8 documents : « Onboarding », « Validation d'entreprise » et 6 autres »), les pages web lues une par une, les requêtes lancées, les noms des pièces jointes. Pendant les trente à cent secondes d'un tour, les grandes étapes suffisent, avec au plus un compte : « 8 documents internes », « 2 pages web ».
Deux repères restent nommés, et ce sont des repères et non des listes : le thème et le périmètre, un nom puis « et N autres ».
stagene change pas, et c'est lui le contrat : une plateforme qui veut ses propres mots le lit et écrit les siens.labelest son rendu français. Les treize valeurs destagesont inchangées, et aucune étape ne disparaît — mais deux étapes voisines qui portaient des libellés différents parce qu'elles nommaient des documents différents n'en portent plus qu'un, donc une seule étape sort là où il en sortait deux.Écrit dans
API.md, «GET …/events— la progression, poussée », etFRONTEND.md§7.
Partout
X-AI-Engine-Contractsur chaque réponse, erreurs et octets compris, etcontract_versiondansGET /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,PATCHetDELETEsur/v1/app/scopeset/v1/tenant/scopes: six routes, arrivées avec la fusion derecentrage-it, jamais annoncées.docs/API.mdtitrait encore sa section « Périmètres » en lecture, etdocs/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, avecbaseline_changedcalculé par la fonction du juge d'admission. - Avant : les 33 routes du contrat plateforme v1, décrites par
API.mdet parcourues parINTEGRATION.md.