# Modifications de schéma API : des tests de contrat pour des agents plus sûrs

Un agent peut transformer une petite modification d'API en véritable erreur d'action plus vite qu'un client utilisé par une personne. Le renommage d'un champ de réponse peut lui faire sélectionner toutes les ressources au lieu d'une seule. Une nouvelle valeur par défaut peut élargir une requête. Un objet de statut modifié peut ressembler à une autorisation de réessayer, et cette nouvelle tentative peut répéter un débit, un déploiement ou une demande de suppression.

Le plus dangereux, c'est que ces échecs ressemblent souvent à un fonctionnement normal dans les outils de supervision classiques. Le fournisseur renvoie HTTP 200. Le client ne plante pas. L'agent produit une explication plausible. Les tests de contrat doivent vérifier le sens que l'agent attribue aux données de l'API, pas seulement si le JSON est correctement analysé.

## Les décisions de l'agent font de la compatibilité une propriété de sécurité

Un client API utilisé par une personne échoue souvent de façon visible quand une réponse change. Un tableau vide s'affiche, une erreur apparaît dans un formulaire et quelqu'un enquête avant de passer à l'action suivante. Les agents transforment souvent directement une réponse en nouvelle requête. Ils peuvent effectuer cette conversion plusieurs fois avant que quiconque ne voie la transcription.

Prenons un agent de nettoyage qui appelle `GET /projects?state=inactive`, lit le champ `owner` de chaque élément et demande une approbation avant d'archiver les projets qui ne figurent pas sur une liste autorisée. Le fournisseur remplace ensuite `owner` par `owner_id`, tout en conservant l'ancien point de terminaison et le même code de statut. Un analyseur permissif transforme `owner` absent en chaîne vide. Si la règle de l'agent considère qu'un propriétaire vide signifie « non attribué », il prépare une demande d'archivage beaucoup plus large.

Ce n'est ni un échec d'authentification ni un problème de prompt. C'est un échec d'interprétation à la frontière de l'API. Le contrôle correctif doit se trouver au même endroit.

Traitez tout champ qui influence l'une de ces décisions comme une partie du contrat de sécurité :

- l'objet sur lequel l'agent peut agir
- l'éligibilité d'un objet à l'action
- la portée, le montant ou la destination de l'action
- le fait qu'une action précédente soit terminée, échouée ou nécessite une nouvelle tentative
- le fait que l'agent doive demander l'avis d'une personne avant de continuer

Cette distinction compte, car la compatibilité du transport est bien plus faible que la compatibilité comportementale. Un service peut conserver son point de terminaison, sa méthode, son mécanisme d'authentification et sa syntaxe JSON, tout en modifiant la décision qui suit la réponse. Les équipes qualifient souvent ce changement de non bloquant parce que les SDK existants continuent à désérialiser les données. Pour un appelant autonome, cette étiquette peut être dangereusement incomplète.

Dressez une petite carte de toutes les valeurs de réponse qui atteignent un sélecteur d'action, une décision d'autorisation ou une branche de nouvelle tentative. Ne commencez pas par chaque propriété d'une immense spécification d'API. Commencez par les valeurs dont une mauvaise interprétation modifie ce que l'agent peut faire.

## Un diff de schéma ne peut pas prouver la compatibilité comportementale

Les outils de comparaison de schémas détectent des changements utiles, mais ils ne peuvent pas dire si un changement est sûr pour un agent donné. Ils comparent des déclarations. Un agent dépend de sémantiques qui se trouvent souvent en dehors de ces déclarations.

Supposons qu'un fournisseur fasse passer `limit` d'un paramètre facultatif avec une valeur par défaut implicite de 100 à un paramètre facultatif avec une valeur par défaut implicite de 1000. Un diff OpenAPI classique peut ne signaler aucun changement de propriété obligatoire. Pourtant, un agent qui omet `limit` peut désormais examiner dix fois plus d'objets et envoyer une action groupée sur l'ensemble.

La spécification OpenAPI décrit un Schema Object comme un surensemble du vocabulaire JSON Schema et indique que ses propriétés fournissent des informations sur les charges utiles des requêtes et des réponses. C'est une documentation et une base de validation utiles. Elle ne dit pas que `state: "pending"` autorise une nouvelle tentative ni qu'un `limit` omis reste plafonné à 100. Ce sont des assertions de workflow, et vos tests doivent les formuler en termes simples.

De même, l'annotation `default` de JSON Schema n'oblige ni un validateur ni un client à insérer une valeur. Beaucoup de développeurs le supposent. La documentation JSON Schema présente `default` comme une donnée d'annotation, pas comme une commande qui modifie une instance. Si votre sécurité dépend d'une valeur, faites-la envoyer explicitement par le client et testez la requête sortante exacte. N'attendez pas d'une annotation de schéma qu'elle corrige une omission.

Utilisez un outil de comparaison comme signal d'alerte. Classez ensuite chaque changement détecté selon le parcours d'action qu'il peut affecter :

- Un identifiant renommé peut modifier la cible sélectionnée.
- L'ajout d'une valeur d'énumération peut envoyer l'analyseur vers une branche non testée.
- La modification d'une valeur par défaut peut changer la portée sans modification du code de requête.
- La modification d'une représentation peut inverser le sens de la réussite ou de l'échec.

L'inverse compte aussi. Un outil peut signaler l'ajout d'un champ descriptif que l'agent ne lit jamais. Cela mérite une revue, mais pas le gel d'une mise en production. La revue de compatibilité gagne en qualité lorsqu'elle suit les données jusqu'à une décision au lieu de considérer chaque ligne du schéma comme présentant le même risque.

## Les tests de contrat doivent figer les requêtes et les décisions

Un test de contrat utile comporte deux parties : il vérifie la requête réellement envoyée par l'agent, puis l'action qu'il propose après avoir lu la réponse du fournisseur. Tester une seule partie laisse un angle mort important.

Pour les valeurs par défaut, capturez une véritable requête HTTP dans un serveur de test local ou une sandbox du fournisseur. L'exemple Python suivant utilise `httpx.MockTransport` pour examiner une requête sortante. Il évite le problème courant d'un client qui dépend silencieusement d'une valeur par défaut du fournisseur pour une opération destructive.

```python
import httpx

seen = []

def handler(request: httpx.Request) -> httpx.Response:
    seen.append({
        "method": request.method,
        "path": request.url.path,
        "query": dict(request.url.params),
    })
    return httpx.Response(200, json={"items": []})

client = httpx.Client(transport=httpx.MockTransport(handler))

response = client.get(
    "https://api.example.test/projects",
    params={"state": "inactive", "limit": "100"},
)

assert response.status_code == 200
assert seen == [{
    "method": "GET",
    "path": "/projects",
    "query": {"state": "inactive", "limit": "100"},
}]
```

L'assertion importante n'est pas la réponse 200. C'est `limit` explicitement transmis. Si une refactorisation supprime ce paramètre, le test échoue avant qu'une nouvelle valeur par défaut du fournisseur puisse élargir la sélection.

Testez ensuite la décision. Séparez la fonction de planification de l'agent du code qui effectue l'appel HTTP afin que le test puisse examiner une action proposée sans en exécuter une.

```python
from dataclasses import dataclass

@dataclass
class ArchivePlan:
    project_ids: list[str]
    requires_approval: bool

def plan_archives(items: list[dict], allowed_owners: set[str]) -> ArchivePlan:
    targets = []
    for item in items:
        owner = item.get("owner")
        if owner is None:
            raise ValueError("provider response lacks owner")
        if item["state"] == "inactive" and owner in allowed_owners:
            targets.append(item["id"])
    return ArchivePlan(targets, requires_approval=bool(targets))

items = [
    {"id": "p17", "state": "inactive", "owner": "team-a"},
    {"id": "p18", "state": "inactive", "owner": "team-b"},
]

plan = plan_archives(items, {"team-a"})
assert plan.project_ids == ["p17"]
assert plan.requires_approval is True
```

Ce test fait un choix que le code permissif évite souvent : l'absence de `owner` déclenche une erreur. Pour un champ qui sert à sélectionner une action, il faut échouer par défaut. Renvoyer une chaîne vide, `None` ou une valeur de repli supposée peut maintenir le processus en marche, mais remplace une panne d'intégration détectable par un plan potentiellement dangereux.

Gardez des jeux de données assez petits pour qu'un relecteur comprenne pourquoi chaque élément apparaît. Un jeu de données de mille objets peut ressembler à la production, mais il masque la condition que vous vouliez protéger.

## Les champs renommés exigent un comportement d'échec explicite

Un champ renommé doit interrompre le parcours d'action, sauf si vous prenez délibérément en charge les deux noms pendant une migration définie. La gestion silencieuse d'une solution de repli semble robuste dans une démonstration, mais elle crée des sémantiques non revues en production.

La pire version ressemble à ceci :

```python
owner = item.get("owner", "")
if owner not in blocked_owners:
    archive(item["id"])
```

Lorsque `owner` disparaît, chaque élément semble avoir un propriétaire qui n'est pas bloqué. L'analyseur a fait exactement ce que le code lui demandait. L'ingénieur qui l'a écrit voulait probablement éviter une `KeyError`. Cette petite commodité a transformé l'absence de données en permission d'agir.

Écrivez des tests pour les trois situations : le champ attendu, l'ancien champ si vous avez une promesse temporaire de compatibilité, et l'absence des deux champs. Le troisième test doit préciser si l'agent s'arrête, ignore l'objet ou demande une clarification. Pour l'identité de la cible, l'état d'autorisation et la portée de l'action, l'arrêt est généralement la bonne réponse.

Si vous prenez en charge un alias, rendez sa priorité évidente et temporaire :

```python
def read_owner(item: dict) -> str:
    if "owner" in item:
        return item["owner"]
    if "owner_id" in item:
        return item["owner_id"]
    raise ValueError("owner identity missing")
```

Ce code nécessite encore un test pour les données contradictoires. Si les deux champs arrivent et ne concordent pas, ne privilégiez pas silencieusement l'un d'eux. Déclenchez une erreur et demandez au fournisseur de lever l'ambiguïté. Une couche de compatibilité doit préserver une ancienne signification connue, pas inventer un critère de départage pour des enregistrements incohérents.

Les équipes soutiennent parfois qu'une analyse permissive protège contre l'évolution du fournisseur. Elle protège contre les ajouts sans danger lorsque vous ignorez les champs inconnus. Elle ne protège pas contre les champs manquants qui gouvernent une action. Dans ces situations, il faut faire l'inverse : accepter par défaut les informations supplémentaires, mais refuser les informations obligatoires absentes.

## Les valeurs par défaut et les omissions exigent des tests distincts

Une propriété omise, une valeur null explicite et une valeur explicite sont trois requêtes différentes. Les agents les confondent souvent, car les sérialiseurs généralistes les confondent eux aussi.

Un générateur de requêtes peut omettre `dry_run` lorsque sa valeur interne est `None`. Le fournisseur peut interpréter cette omission comme `false`. Une version ultérieure du fournisseur peut changer cette interprétation et utiliser le paramètre du compte, lequel vaut false pour un locataire et true pour un autre. Le code de l'agent n'a pas changé, mais l'action, elle, a changé.

Placez les options qui influencent une décision dans l'une de deux catégories. Lorsque le comportement sûr est connu, envoyez toujours la valeur. Lorsque l'option nécessite un choix de l'opérateur, exigez ce choix avant de construire la requête. Évitez une troisième catégorie « laisser le serveur décider » pour les actions destructives ou visibles de l'extérieur.

Testez la sérialisation avec un tableau de cas exacts. Le but est d'examiner la représentation sur le réseau, pas seulement l'objet du langage avant la sérialisation.

| Intention | Représentation sortante | Signification attendue côté fournisseur |
| --- | --- | --- |
| Lire les projets inactifs | `state=inactive&limit=100` | Une sélection limitée |
| Simuler l'archivage | `{"dry_run": true}` | Aucun archivage n'a lieu |
| Archiver un projet | `{"project_ids":["p17"],"dry_run": false}` | Seul p17 peut être modifié |
| Aucun choix de l'opérateur pour le mode | La requête est refusée localement | Le fournisseur ne reçoit rien |

Soyez tout aussi rigoureux avec la pagination. Une réponse qui ajoute `next_cursor` peut pousser un agent à récupérer automatiquement toutes les pages. C'est parfois raisonnable pour un rapport et imprudent pour un planificateur d'actions. Testez à la fois le nombre maximal d'objets que le planificateur peut examiner et la condition qui autorise une deuxième page. Un curseur permet de continuer, il ne donne pas son accord pour une portée illimitée.

Les valeurs par défaut du fournisseur comptent aussi dans les réponses. Si une API commence à omettre `archivable` lorsqu'il vaut false, un code tel que `if item.get("archivable", True)` modifie son comportement dans la mauvaise direction. Pour un champ qui accorde une permission, utilisez une comparaison explicite comme `item.get("archivable") is True`. C'est moins élégant et beaucoup plus facile à auditer.

## La validation des réponses doit préserver le sens, pas seulement la forme

La validation des réponses doit distinguer les données mal formées des données inconnues mais inoffensives. Une stricte rigidité généralisée casse les clients lorsque les fournisseurs ajoutent des champs. Une permissivité généralisée transforme les preuves manquantes en suppositions.

Définissez un modèle de réponse limité aux valeurs qui alimentent l'action suivante. Pour chaque valeur, indiquez le type, les états autorisés et la conséquence de son absence sur le workflow. Un identifiant de projet ne doit pas être seulement une `string` : l'agent a besoin d'un identifiant stable et non vide, identique à celui qu'il enverra ensuite dans la requête d'archivage. Un statut ne doit pas être seulement une `string` : l'agent a besoin d'un état énuméré et d'une action documentée pour chaque valeur.

Par exemple, cet analyseur gère une représentation de statut modifiée sans s'autoriser une nouvelle tentative :

```python
ALLOWED_STATES = {"queued", "running", "succeeded", "failed"}

def retryable(job: dict) -> bool:
    status = job.get("status")
    if status not in ALLOWED_STATES:
        raise ValueError(f"unknown job status: {status!r}")
    return status == "failed" and job.get("retry_allowed") is True
```

Si le fournisseur remplace `status` qui était une chaîne par un objet tel que `{"phase":"failed"}`, ce code s'arrête. Cette interruption est correcte tant que personne n'a décidé comment la nouvelle représentation correspond à l'ancien workflow. Si le fournisseur ajoute `cancelled`, l'arrêt est également correct jusqu'à ce que l'équipe décide si l'annulation est définitive, permet une nouvelle tentative ou nécessite l'intervention d'une personne.

Ne transformez pas chaque valeur d'énumération inconnue en urgence pour un affichage en lecture seule. La réaction doit correspondre à l'action. Un agent de reporting peut signaler un état inconnu et continuer. Un agent qui va relancer une tâche de facturation ou supprimer une ressource doit s'arrêter avant d'agir sur un état qu'il ne comprend pas.

Testez aussi les relations entre les champs. Une réponse peut être valide sur le plan structurel tout en contenant une combinaison contradictoire, comme `status: "succeeded"` et `retry_allowed: true`. La validation du schéma n'exprime généralement pas toutes les invariantes métier. Un test de contrat doit vérifier qu'une tâche réussie ne produit aucun plan de nouvelle tentative, quel que soit un booléen incohérent.

## Testez tout le parcours d'action, pas un mock pratique

Les tests unitaires des analyseurs sont nécessaires, mais ils ne prouvent pas que l'agent déployé envoie la requête prévue avec ses véritables identifiants et son véritable parcours d'exécution. Les bibliothèques de sérialisation, les wrappers, les adaptateurs d'outils et les middleware de nouvelle tentative modifient tous le comportement d'une manière qu'un appel direct de fonction ne peut pas révéler.

Exécutez en CI un serveur de contrat local qui enregistre les requêtes et renvoie des jeux de données versionnés. Configurez l'outil de l'agent pour pointer vers ce serveur. Le test doit partir d'une instruction réaliste, attendre le plan proposé ou l'enregistrement de l'action, puis vérifier la séquence enregistrée : méthode, chemin, requête, en-têtes qu'il est possible d'inspecter sans risque, corps et nombre d'actions.

Ne placez pas de secrets réels dans cet environnement. Utilisez un identifiant de test sans aucun pouvoir et vérifiez que l'agent ne le reçoit jamais dans son prompt, le résultat de l'outil, le texte d'une exception ou une trace. Un test qui journalise les en-têtes de requête sans précaution peut recréer l'exposition qu'il était censé empêcher.

Pour les agents qui effectuent des appels HTTP ou SSH externes avec Sallyport, le parcours d'action peut garder les identifiants hors de l'agent tout en conservant un résultat vérifiable. Cette frontière ne corrige pas une mauvaise interprétation de la réponse. Exécutez donc les contrats de schéma et de décision avant d'autoriser l'action externe.

Ajoutez des jeux de données d'échec, pas seulement des réponses de référence. Reproduisez les conditions exactes observées lors des transitions de fournisseur : champ de sélection manquant, nouvelle valeur d'énumération, résultat vide avec curseur de continuation, changement de type de contenu et réponse 200 contenant un objet d'erreur. Un corps d'erreur avec un statut 200 est particulièrement courant dans les anciennes API. Si votre analyseur suppose que chaque réponse 200 contient la forme de réussite, il peut fabriquer un plan vide ou répéter une requête déjà réussie.

Lors des tests de nouvelles tentatives, vérifiez le comportement d'idempotence. Faites en sorte que le serveur de contrat renvoie d'abord un délai d'attente après avoir enregistré la requête, puis une seconde réponse à la nouvelle tentative. Le test doit prouver que le client envoie un jeton d'idempotence lorsque l'API en prend un en charge, ou qu'il s'arrête et demande une confirmation lorsqu'il ne peut pas savoir si la première action est terminée. Répéter une lecture est généralement sans danger. Répéter un transfert, un e-mail, un déploiement ou une suppression ne l'est pas.

## Les contrôles de publication doivent bloquer les ruptures sémantiques

Exécutez les comparaisons de schémas, les tests de contrat du fournisseur et les tests de décision de l'agent dans le parcours de modification, à la fois pour les producteurs d'API et pour les consommateurs agents. Un processus qui ne les exécute qu'après le déploiement transforme les tests en documentation d'incident.

Pour une modification du fournisseur, exigez une trace de revue qui réponde à quatre questions concrètes : quelles hypothèses des consommateurs changent, quel était l'ancien comportement, combien de temps les deux comportements resteront pris en charge et quel jeu de données illustre le nouveau comportement. Cela demande moins de travail que de reconstituer une régression en production avec des journaux incomplets.

Pour une modification de l'agent, exécutez la suite de jeux de données du fournisseur avant la fusion. Si l'agent utilise désormais un champ nouvellement ajouté, ajoutez des jeux de données où ce champ est absent et où ses valeurs sortent du parcours nominal. Les changements dans la formulation du prompt peuvent aussi modifier les arguments des outils. Testez donc l'appel d'outil produit par l'agent complet au lieu de supposer que le planificateur choisira toujours les mêmes paramètres.

Les jeux de données versionnés rendent la revue concrète. Conservez un identifiant tel que `projects-list-v1` avec la paire requête-réponse attendue. Lorsqu'un fournisseur introduit volontairement `projects-list-v2`, gardez l'ancien jeu de données jusqu'à la fin de la politique de migration. Ne remplacez pas l'ancien JSON en prétendant que les tests sont à jour. Vous perdriez la preuve de la compatibilité abandonnée.

Un contrôle utile signale les échecs dans un langage opérationnel. « Champ owner manquant, planification de l'archivage interrompue » indique au relecteur ce qui s'est passé. « ValidationError at path items.0 » vaut mieux que rien, mais oblige le relecteur à reconstituer le risque pendant la publication.

## Les écrans d'approbation ne peuvent pas corriger un plan trompeur

L'approbation humaine reste un bon contrôle pour les actions externes, mais elle arrive trop tard si l'agent a construit le mauvais plan à partir d'un contrat modifié. Une personne qui voit « Archiver 847 projets inactifs » peut le refuser. Une personne qui voit « Archiver le projet p17 » ne peut pas savoir si p17 vient d'un champ `owner` absent, d'une valeur par défaut élargie ou d'un analyseur qui a confondu `cancelled` et `failed`.

Dans les demandes d'approbation, incluez les éléments de décision qui méritent une revue humaine : identifiants des cibles, nombre d'objets, mode demandé et champs de réponse qui ont rendu la cible éligible. Gardez l'enregistrement compact. Afficher le JSON brut à la personne qui approuve lui transfère la tâche d'analyse, alors qu'elle est déjà fatiguée.

Conservez séparément une trace permettant à un ingénieur de reconstituer l'action. Capturez la réponse du fournisseur ou une empreinte protégée de celle-ci, la version de l'analyseur, la version du jeu de données de contrat, la requête générée et la réponse obtenue. Une piste d'audit inviolable aide après coup, mais elle doit renvoyer à la frontière de décision au lieu d'enregistrer seulement qu'un appel HTTP a eu lieu.

La prochaine fois qu'une équipe API affirme qu'une modification de réponse est cosmétique, demandez-lui d'exécuter la suite de contrats de l'agent. Si la suite échoue, le changement a un comportement associé. Corrigez-le avant qu'une réponse 200 bien polie ne devienne une action dangereuse.
