# Les erreurs du courtier de secrets exigent des preuves

Un courtier de secrets doit rapporter ce que ses journaux prouvent, pas ce qu'une exception laisse entendre. S'il ne peut pas prouver que l'opération distante n'a pas été tentée, il ne doit pas qualifier l'appel d'échec préalable. Dès que des octets de la requête ou une demande `exec` SSH ont pu atteindre l'autre côté, le résultat peut être inconnu, même si l'erreur locale indique «timeout» ou «connection reset».

Cette différence détermine si un agent crée un second paiement, renouvelle deux fois le même identifiant, redéploie la même version ou répète sans risque un travail qui n'a jamais quitté la machine. Un contrat d'erreur utile contient donc deux faits indépendants: l'endroit où le courtier s'est arrêté et ce qu'il sait de l'exécution distante. Un seul champ «transient» ne peut pas exprimer les deux.

La conception ci-dessous s'applique aux appels HTTP et aux commandes SSH. Elle suppose aussi que le courtier possède un journal d'action durable. Un état conservé uniquement en mémoire peut améliorer un message d'erreur, mais il ne peut pas justifier une nouvelle tentative après le redémarrage du courtier ou de l'appelant.

## Une cause n'est pas un résultat

Chaque échec nécessite une phase, un résultat et une consigne de nouvelle tentative. La phase indique à l'opérateur où chercher. Le résultat indique à l'appelant si le système distant a pu changer d'état. La consigne indique à l'automatisation ce qu'elle peut faire maintenant, à partir des preuves enregistrées et de la sémantique de l'opération.

Utilisez cinq phases publiques:

- `validation`: l'action a été refusée avant que le courtier choisisse ou utilise un secret.
- `credential_injection`: le courtier n'a pas pu obtenir, autoriser ou joindre l'identifiant.
- `connection_setup`: la résolution de nom, le routage, TCP, TLS, le transport SSH, la vérification de l'hôte ou l'authentification distante ont échoué avant l'envoi.
- `remote_execution`: le courtier a envoyé l'opération et attend le résultat distant, ou l'a déjà reçu.
- `result_delivery`: le courtier a enregistré le résultat distant, mais n'a pas pu le remettre intact à l'appelant.

Ces phases servent au diagnostic, pas à la politique de relance. Une erreur `connection_setup` peut prouver qu'aucune requête applicative n'a été envoyée, tandis qu'une connexion réutilisée qui se rompt peut laisser le courtier dans le doute sur la réception des données par l'autre côté. Une erreur `result_delivery` peut accompagner un succès distant connu. Traiter les deux comme une simple erreur réseau détruit le fait dont l'appelant a besoin.

Utilisez quatre états de résultat:

- `not_attempted`: des preuves durables montrent que l'opération distante n'a pas franchi la limite d'envoi.
- `rejected`: le système distant a renvoyé un refus complet et faisant autorité, sans annoncer de succès.
- `committed`: le courtier dispose d'un résultat complet et faisant autorité pour l'opération.
- `unknown`: l'envoi a pu avoir lieu, mais le courtier n'a pas de résultat complet qui tranche le sort de l'opération.

`committed` ne signifie pas réussite. Une commande qui se termine avec le code 23 ou une requête HTTP qui reçoit une réponse 500 complète ont un résultat connu. L'application distante s'est exécutée assez loin pour répondre. Dire qu'elle «n'a pas été exécutée» invite à créer un doublon.

La consigne de nouvelle tentative doit être tout aussi explicite: `never`, `after_correction`, `backoff`, `same_idempotency_key`, `reconcile` ou `fetch_result`. Le courtier la calcule à partir des preuves, de la sémantique de la méthode, des garanties distantes et de l'état de son journal de résultats. L'appelant ne doit jamais la déduire d'un texte d'erreur.

Ne fusionnez pas `rejected` et `committed` dans une valeur publique unique appelée `known`. Un refus peut permettre une requête corrigée, tandis qu'un résultat validé oblige l'appelant à le consommer ou à le rapprocher de l'état distant. Les deux donnent une certitude sur la tentative, mais conduisent à des traitements différents.

Une acceptation asynchrone exige une preuve supplémentaire, pas un nouvel état. Une réponse HTTP 202 complète est un résultat validé pour la soumission, pas la preuve que la tâche mise en file d'attente est terminée. Enregistrez l'identifiant de la tâche et la ressource de suivi fournis par le service, puis suivez cette tâche comme une opération distincte. Répéter la soumission parce qu'elle reste en attente peut la placer deux fois dans la file.

Cette séparation précise une distinction que beaucoup de SDK brouillent. La cause de l'erreur répond à «qu'est-ce qui a cassé localement?». Le résultat distant répond à «qu'est-ce qui a peut-être déjà eu lieu?». Un moteur de relance qui ne lit que la cause n'est pas sûr.

## La validation et l'injection échouent avant l'envoi

Les erreurs de validation ne sont de véritables échecs préalables que tant que le courtier n'a pas ouvert de canal capable de transporter l'action. Refusez les destinations mal formées, les méthodes non prises en charge, les champs manquants, les charges trop volumineuses, les références d'identifiants inconnues et les remplacements d'en-têtes interdits avant de résoudre un hôte ou d'accéder à un secret. Enregistrez `phase=validation`, `outcome=not_attempted` et, le plus souvent, `retry=after_correction`.

Une relance automatique ne corrige pas un échec de validation déterministe. Répéter la même charge invalide gaspille des ressources et peut masquer une boucle de l'agent. Renvoyez un code stable comme `INVALID_TARGET`, `UNSUPPORTED_ACTION` ou `PAYLOAD_LIMIT`, ainsi que le chemin du champ que l'appelant peut corriger. Ne renvoyez pas la référence refusée si son nom contient des informations sensibles.

L'injection des identifiants reste une étape préalable lorsque le courtier échoue avant de libérer le moindre octet de requête distante. Un coffre verrouillé, une approbation refusée, un identifiant absent, un mode d'injection non pris en charge et un échec local de déchiffrement appartiennent à cette phase. Le résultat reste `not_attempted`, mais la consigne change. Un coffre verrouillé peut permettre une relance après une action de l'utilisateur; un refus d'approbation doit généralement donner `never` pour cette invocation; un secret absent exige une correction.

Écartez les données d'identification de l'erreur et du journal d'action. Enregistrez l'identifiant du secret ou une version non sensible, le mode d'injection et la décision qui a arrêté l'appel. Journaliser un en-tête `Authorization` préparé pour prouver l'injection annule l'intérêt du courtier.

La limite est subtile. Si le courtier construit une requête HTTP complète avec l'identifiant dans un tampon privé et échoue avant de l'écrire, l'action distante n'a toujours pas été tentée. S'il remet ce tampon à une API de transport et que celle-ci renvoie une écriture partielle ou ambiguë, l'injection a réussi et l'envoi a pu commencer. Classez l'échec selon la dernière limite prouvée, pas selon la fonction dont la pile a capturé l'exception.

L'étape préalable a aussi besoin d'un instantané de configuration. Si la validation utilise une définition de route et que l'envoi lit ensuite une définition modifiée, les preuves ne décrivent plus l'action exécutée. Avant l'autorisation, liez à l'invocation la destination normalisée, la version de l'identifiant, le mode d'injection permis et l'empreinte de la requête. Si une valeur liée change, créez une nouvelle invocation au lieu de modifier l'ancien journal.

C'est particulièrement important pendant une approbation humaine. Une carte d'approbation peut rester ouverte tandis qu'un agent ou un rechargement de configuration modifie le corps, l'hôte ou le secret choisi. Le courtier doit approuver l'empreinte qu'il va envoyer et la vérifier de nouveau juste avant l'écriture. Une différence donne `validation/not_attempted`; elle n'autorise pas l'envoi de la nouvelle requête avec l'ancienne approbation.

## La connexion a besoin d'un point final précis

Une nouvelle connexion qui échoue avant l'existence d'un canal applicatif prouve généralement `not_attempted`. Un échec DNS ou de routage, un refus de connexion TCP, le rejet d'un certificat TLS ou d'une clé d'hôte SSH, ainsi qu'un échec d'authentification SSH ont tous lieu avant qu'une requête HTTP ou une commande SSH puisse s'exécuter. Enregistrez l'étape exacte atteinte pour que l'appelant distingue un mauvais nom d'hôte d'un identifiant refusé sans voir le secret.

L'expression «échec de connexion» est trop large pour un client HTTP qui réutilise des connexions. Lorsque le courtier prend une connexion existante, la configuration est déjà terminée. Une écriture peut échouer parce que l'autre côté a fermé une socket inactive. Le système d'exploitation peut signaler un tube brisé après l'arrivée de certains octets, ou après la réception de toute la requête mais avant que le client n'observe la fermeture. Cet échec appartient à `remote_execution` avec `outcome=unknown`, sauf si le transport fournit une preuve plus forte.

N'utilisez pas «l'appel d'écriture local a confirmé zéro octet» comme preuve que le système distant n'a rien reçu. Une API tamponnée peut accepter localement des octets avant un échec ultérieur, et une écriture échouée révèle peu de choses sur ce que l'autre côté a déjà lu. La limite utile se trouve lors de la première remise à un transport capable d'acheminer des données applicatives. Une fois cette limite franchie, le résultat par défaut devient `unknown`.

La connexion ne se termine pas au même point pour HTTP et SSH. Pour HTTPS, terminez DNS, TCP, TLS, la vérification du certificat et tout tunnel mandataire avant l'envoi. Pour SSH, terminez la négociation du transport, la vérification de l'hôte, l'authentification, la création du canal de session et toute préparation requise de l'environnement. Rien de tout cela ne prouve qu'une commande ultérieure a démarré, mais un échec à ce stade peut prouver qu'elle n'a jamais été demandée.

Une redirection crée une seconde limite d'envoi. Une réponse 307 ou 308 complète tranche le premier échange HTTP, mais la suivre crée une nouvelle requête vers une autre destination. Validez à nouveau la destination, la portée de l'identifiant et la méthode avant cette requête. Ne transmettez jamais un identifiant d'autorisation entre origines uniquement parce qu'une bibliothèque suit automatiquement les redirections.

Un mandataire HTTP ajoute un observateur sans supprimer l'incertitude. Un tunnel réussi prouve seulement que le mandataire a ouvert un chemin. Un mandataire de transfert peut renvoyer une erreur complète sur sa propre tentative, et RFC 9209 peut préciser où le transfert a échoué, mais le résultat à l'origine peut rester inconnu. Enregistrez le saut qui a produit la preuve et ne présentez pas la certitude de l'intermédiaire sur sa réponse comme une certitude sur les effets à l'origine.

Un courtier peut relancer la connexion en interne lorsque chaque tentative possède son propre journal et qu'aucune n'a franchi la limite d'envoi. Il doit limiter les tentatives et les exposer dans un seul journal d'action:

```json
{"attempts":[{"n":1,"stage":"tcp_connect","outcome":"not_attempted","code":"ECONNREFUSED"},{"n":2,"stage":"tls_handshake","outcome":"not_attempted","code":"CERT_EXPIRED"}]}
```

L'erreur finale ne doit pas effacer les preuves précédentes. Elle doit indiquer que l'opération est restée non tentée lors des deux essais et qu'une correction est nécessaire.

## L'envoi change la charge de la preuve

L'instant de l'envoi doit correspondre à une transition d'état explicite et durable. Avant la première remise au transport, ajoutez `dispatch_started` au journal d'action et forcez sa persistance selon la garantie du système. Si le processus s'arrête après l'écriture mais avant cette transition, un redémarrage peut présenter à tort l'opération comme non tentée.

L'écriture préalable stricte ajoute de la latence, ce qui incite les équipes à journaliser après l'envoi. Cette recommandation est populaire parce que le chemin normal devient plus rapide et que le code paraît plus simple. Elle est mauvaise pour les actions non idempotentes. La panne rare tombe précisément dans l'intervalle où le courtier doit choisir entre perdre le travail et le dupliquer.

L'état écrit au préalable ne prouve pas que le système distant a reçu la requête. Il déplace volontairement l'incertitude du côté sûr. Après `dispatch_started`, le résultat commence à `unknown`. Une réponse ultérieure faisant autorité peut le faire passer à `rejected` ou `committed`. Le courtier ne le ramène jamais à `not_attempted`.

Pour HTTP, l'envoi commence avant que le premier octet de la requête n'entre dans la connexion. Suivez l'envoi des en-têtes, l'envoi du corps complet, la réception des en-têtes de réponse et la réception du corps complet. Ces marqueurs aident au diagnostic, mais `request_body_sent=true` ne prouve pas que l'application a traité la requête. À l'inverse, `request_body_sent=false` ne prouve pas qu'elle n'a rien fait; un serveur peut refuser ou exécuter une action à partir des en-têtes avant de lire tout le corps.

Pour SSH, l'envoi commence avant que la demande de canal `exec` n'entre dans le transport authentifié. Utilisez `want reply=true`. RFC 4254 indique que le serveur répond par un succès ou un échec de canal, mais le succès signifie seulement qu'il a accepté la demande de lancement de la commande. Il ne signifie ni que la commande est terminée ni que ses effets peuvent être répétés.

Une annulation après l'envoi n'est pas un échec préalable. Si l'appelant dépasse son délai et ferme le canal, le processus distant peut continuer à s'exécuter. Indiquez `CALLER_CANCELLED` comme cause locale et conservez `outcome=unknown` jusqu'à ce qu'un résultat distant enregistré tranche la question. L'annulation décrit l'intérêt de l'appelant, pas l'état distant.

Les requêtes groupées ont besoin d'un résultat par élément. Si le courtier envoie cinq changements dans une requête HTTP et reçoit une réponse complète qui n'en tranche que quatre, il ne peut pas attribuer sans risque une seule consigne au groupe. Enregistrez le résultat de transport parent et cinq résultats enfants. Ne relancez que l'enfant dont les journaux et le contrat distant l'autorisent, ou rapprochez tout le groupe si l'API applique les changements de façon atomique.

La même règle s'applique à un script shell envoyé par SSH. Un code de sortie couvre le processus du script, pas nécessairement chaque effet qu'il a tenté. Si les appelants ont besoin de décisions par action, donnez à chaque opération son propre identifiant distant et son propre journal de résultat au lieu de déduire la progression de stdout.

## Un résultat distant complet tranche l'exécution

Une réponse complète et faisant autorité transforme l'incertitude en résultat connu. Pour HTTP, enregistrez le statut final, les en-têtes non sensibles choisis, le corps complet ou son condensat, et la fin de l'encadrement. Pour SSH, enregistrez l'acceptation de la commande, la complétude de stdout et stderr, le code de sortie ou le signal lorsqu'ils existent, ainsi que la fermeture du canal.

RFC 9112 impose à un client d'enregistrer une réponse HTTP comme incomplète lorsque la connexion se ferme trop tôt ou que le décodage par blocs échoue. Un courtier de secrets doit appliquer une règle plus stricte: ne jamais présenter un corps partiel comme un résultat distant complet, même si ses premiers octets ressemblent à du JSON valable. Renvoyez le contenu partiel uniquement dans un champ de diagnostic clairement marqué, ou supprimez-le s'il peut contenir des données sensibles.

Le statut HTTP seul ne définit pas si la répétition de l'action est sûre. Un 401 complet prouve que le serveur a refusé ces identifiants pour cette requête, le courtier peut donc marquer le résultat `rejected`; une relance inchangée est inutile. Un 429 ou 503 complet peut autoriser `backoff` lorsque la méthode est répétable et que la réponse indique un délai approprié. Un 500 complet est connu, mais l'application peut avoir changé d'état avant de le produire. Ne transformez pas chaque 5xx en permission de répéter un POST.

RFC 9110 définit l'idempotence par l'effet attendu de plusieurs requêtes identiques et autorise la relance automatique des méthodes idempotentes après une erreur de communication. La précision utile est «connue comme idempotente». Le nom de la méthode est une preuve, pas un pouvoir spécial. Un endpoint GET mal conçu qui lance un déploiement est dangereux malgré son nom; un PUT bien réalisé peut être répétable même s'il change l'état.

Les réponses HTTP informatives ne tranchent pas l'action. `100 Continue` autorise le client à envoyer le corps, sans rien dire du résultat final de l'application. Les autres réponses 1xx laissent aussi l'invocation en cours. Seule une réponse finale complète, ou un reçu applicatif plus fort dont le courtier comprend le contrat, peut faire sortir le résultat de `unknown`.

La complétude et l'authenticité de la réponse doivent aller ensemble. Une réponse parfaitement encadrée provenant d'une mauvaise identité TLS, d'un hôte SSH non approuvé ou d'un mandataire inattendu ne constitue pas une preuve sur la destination voulue. La vérification d'identité se termine normalement pendant la connexion, mais les sessions reprises et les groupes de connexions doivent encore lier l'identité vérifiée du pair au journal d'action.

RFC 9209 définit `http_response_incomplete` pour un intermédiaire qui n'a reçu qu'une partie de la réponse du saut suivant. Son statut 502 recommandé facilite la compatibilité HTTP, mais `502` seul perd les preuves sur le résultat. Conservez le champ structuré `outcome=unknown` du courtier à côté de tout statut traduit.

SSH présente un piège comparable. RFC 4254 recommande au serveur de renvoyer `exit-status`, sans l'imposer. Si le canal se ferme après stdout sans code de sortie ni signal, le courtier sait que le flux s'est terminé, mais pas si la commande a réussi. Renvoyez `REMOTE_RESULT_INCOMPLETE` et choisissez `unknown`, sauf si le contrat de l'action définit un autre marqueur de fin faisant autorité.

## La remise du résultat ne doit pas répéter l'exécution

La remise commence seulement après le stockage durable d'un résultat distant tranché. Si la sérialisation vers l'appelant échoue, si le tube MCP se ferme ou si le processus appelant s'arrête, l'opération distante ne redevient pas inconnue. Indiquez `phase=result_delivery`, conservez `outcome=committed` ou `rejected`, et utilisez `retry=fetch_result`.

Cette phase a besoin d'un identifiant d'invocation permettant à l'appelant de récupérer le résultat stocké. Une nouvelle soumission d'action n'est pas une récupération. Séparez les deux opérations pour qu'une bibliothèque cliente générique ne transforme pas accidentellement un tube de réponse rompu en second appel distant.

L'ordre des écritures compte:

1. Terminer et valider la réponse distante.
2. Ajouter le résultat tranché et son condensat au journal durable.
3. Valider le résultat récupérable sous l'identifiant d'invocation.
4. Remettre le résultat à l'appelant.

Si l'étape 4 échoue, les étapes 2 et 3 prouvent ce qui s'est passé. Si le courtier remet d'abord et journalise ensuite, une panne peut laisser l'appelant avec un succès alors que l'audit indique un résultat inconnu. C'est un défaut d'audit, même sans relance immédiate.

Les résultats volumineux ou diffusés en continu suivent la même règle. Stockez les fragments avec des numéros de séquence et un marqueur final de complétude. L'appelant peut reprendre la remise depuis le dernier fragment vérifié, mais le courtier ne doit pas déclarer le résultat complet avant d'avoir le terminateur, la longueur annoncée ou la fermeture propre au protocole qui le prouve.

L'accusé de réception de l'appelant sert à la conservation, pas au résultat distant. Marquez `RESULT_DELIVERED` uniquement après confirmation d'une remise complète par le protocole côté appelant. En l'absence d'accusé, gardez le résultat disponible jusqu'à l'expiration de la politique et traitez les lectures répétées comme des lectures. Ne relancez jamais l'action pour reconstruire un résultat que le courtier a choisi de ne pas conserver.

Le stockage du résultat peut échouer après la fin distante. Si le courtier détient la réponse complète en mémoire mais ne peut pas la valider sur disque, il en sait plus qu'un simple `unknown`, mais les preuves ne survivront pas à une panne. Renvoyez `phase=result_delivery`, incluez `outcome=committed` uniquement si le contrat de durabilité autorise cette affirmation à partir du journal actuel, et exigez une intervention immédiate. La bonne correction consiste à réserver de la capacité et à tester les pannes de stockage, pas à relancer l'action distante.

## L'idempotence est un contrat distant

Une clé d'idempotence rend un résultat inconnu répétable uniquement lorsque le service distant promet de la lier à une opération logique. Générer un UUID dans le courtier et le journaliser ne suffit pas. L'endpoint distant doit accepter la clé, comparer l'empreinte de la requête, conserver le premier résultat tranché pendant une durée suffisante et le renvoyer en cas de répétition.

Le courtier doit enregistrer quatre faits avant l'envoi: la clé d'idempotence, l'empreinte de la requête, la portée distante et les informations d'expiration ou de conservation publiées par le service. Lors d'une relance, il doit réutiliser la même clé et une empreinte identique. Réutiliser une clé avec un corps modifié doit échouer localement avec `IDEMPOTENCY_MISMATCH`.

N'ajoutez pas discrètement un en-tête d'idempotence aux endpoints qui ne définissent aucune sémantique pour lui. Certains services ignorent les en-têtes inconnus. D'autres limitent les clés à un compte ou une route. Une politique de relance exige une connaissance configurée et vérifiée du contrat distant, pas un espoir fondé sur le nom d'un en-tête.

Les fenêtres de conservation font partie du contrat. Si un service oublie les clés après une journée, une relance plus tardive peut créer un nouvel effet tout en paraissant identique au courtier. Enregistrez l'expiration sûre la plus proche, arrêtez les relances automatiques avant celle-ci et procédez ensuite à un rapprochement. Si le service ne publie aucune garantie de conservation, considérez la clé comme utile uniquement pendant une fenêtre prudente configurée.

La concurrence peut déjouer une conception correcte en exécution séquentielle. Deux workers peuvent lire le même journal inconnu et décider de relancer avec la même clé. Un contrat distant de déduplication correct devrait les regrouper, mais le courtier doit tout de même prendre un bail sur l'invocation, enregistrer la génération de relance et n'autoriser qu'une tentative active. Cela réduit la charge et garde le journal compréhensible.

Trois chemins seulement sont sûrs à partir de `unknown`:

- Répéter une opération dont la sémantique est connue comme idempotente.
- Répéter avec la même clé dans le cadre d'un contrat distant de déduplication vérifié.
- Rapprocher en interrogeant l'état distant avec un identifiant stable, puis décider si une nouvelle action est nécessaire.

Tout le reste s'arrête pour examen. Cela peut sembler prudent pendant qu'un agent attend, mais les effets dupliqués coûtent plus cher qu'une pause visible.

Les requêtes HTTP conditionnelles peuvent renforcer le contrat. `If-Match` avec une étiquette d'entité connue peut faire échouer une mise à jour si la ressource a changé, tandis que `If-None-Match: *` peut empêcher la création d'une seconde ressource à la même destination. Elles ne règlent pas tous les doublons, car le modèle de ressources de l'endpoint compte toujours, mais elles fournissent des preuves imposées par le serveur plutôt qu'une supposition du client.

Les commandes SSH offrent rarement une clé d'idempotence au niveau du protocole. Placez la répétabilité dans le contrat applicatif de la commande: créez un déploiement sous un identifiant de version unique, écrivez avec une comparaison atomique ou lancez une requête qui confirme l'état attendu. Ne supposez jamais qu'une commande shell est répétable parce qu'elle n'a rien affiché.

## L'enveloppe d'erreur doit porter les preuves

Un appelant a besoin d'un contrat machine stable et d'un court message lisible. Gardez les exceptions de la bibliothèque de transport dans un champ de diagnostic interne, car leurs noms varient entre plateformes et révèlent l'implémentation. L'enveloppe publique doit ressembler à ceci:

```json
{"invocation_id":"act_01J...","error":{"code":"REMOTE_OUTCOME_UNKNOWN","phase":"remote_execution","outcome":"unknown","retry":"same_idempotency_key","message":"Connection closed before a complete response was recorded."},"evidence":{"dispatch_started":true,"request_complete":true,"response_headers_received":false,"response_complete":false,"idempotency":{"key":"req_01J...","scope":"payments.create","fingerprint":"sha256:8b1...","remote_contract":"configured"}}}
```

Gardez `code`, `phase`, `outcome` et `retry` comme des énumérations fermées. Ajoutez de nouveaux champs de preuve sans changer leur signification. Les appelants peuvent brancher leur traitement sur les énumérations et afficher `message` à une personne. Ils ne doivent pas analyser le texte.

Les preuves doivent dire comment le courtier sait, pas seulement répéter la conclusion. Les champs utiles comprennent le numéro de tentative, l'identifiant de connexion, la séquence du journal d'envoi, l'empreinte de la requête, le marqueur de fin du protocole, l'identifiant distant, le condensat de réponse, le code de sortie et l'identifiant du journal de résultat. Omettez les secrets, les en-têtes d'autorisation complets, les clés privées et les corps distants non filtrés.

Conservez les transitions sous forme d'événements ajoutés uniquement, puis projetez l'état actuel:

```text
ACTION_ACCEPTED
PREFLIGHT_VALIDATED
CREDENTIAL_AUTHORIZED
DISPATCH_STARTED
REQUEST_SENT
REMOTE_RESPONSE_STARTED
REMOTE_RESPONSE_COMPLETE
RESULT_COMMITTED
RESULT_DELIVERED
```

Une action qui se termine après `PREFLIGHT_VALIDATED` est prouvée comme non tentée. Une action qui se termine après `DISPATCH_STARTED` mais avant une fin faisant autorité reste inconnue. Une action avec `RESULT_COMMITTED` peut survivre à une remise échouée sans nouvelle exécution distante.

La projection doit refuser les régressions impossibles. `unknown` peut devenir `rejected` ou `committed` lorsque des preuves tardives arrivent, mais `committed` ne peut pas devenir `not_attempted`. Un second observateur peut joindre un résultat de rapprochement, sans réécrire la tentative d'origine comme si l'envoi n'avait jamais eu lieu.

Enregistrez des numéros de séquence monotones plutôt que de dépendre de l'ordre de l'horloge. Les horloges peuvent bouger et les événements de composants concurrents arriver en retard. Les horodatages aident les opérateurs à corréler les systèmes, mais la séquence du journal définit l'ordre des transitions durables. Joignez une source et une séquence locale aux preuves distantes importées au lieu de les insérer au milieu de l'historique.

Les preuves doivent aussi déclarer leur source de confiance. `transport_observed`, `remote_response`, `remote_query` et `operator_attested` indiquent au code ultérieur pourquoi le résultat a changé. Un opérateur peut légitimement trancher une tentative inconnue après avoir vérifié le système distant, mais ce fait ne doit pas se faire passer pour une réponse reçue par le courtier sur la connexion d'origine.

L'intégrité de l'audit et les preuves du résultat répondent à des problèmes différents. Une chaîne de hachage peut prouver que les événements enregistrés n'ont pas été modifiés ensuite; elle ne peut pas prouver que le courtier a tout enregistré ni que l'application distante a respecté la requête. Le journal d'audit chiffré, chaîné par hachage et aveugle à l'écriture de Sallyport, ainsi que ses vues distinctes des sessions et de l'activité, offrent un emplacement durable pour ces transitions; le résultat d'action a toujours besoin du contrat de phase et de résultat décrit ici.

## Le code de relance doit être simple et testable

Le moteur de relance doit consommer la consigne déjà dérivée des preuves. Il peut ajouter des limites de débit et de tentatives, mais ne doit pas rendre une consigne dangereuse plus permissive parce qu'une exception paraît temporaire.

```text
decide(record, operation):
  if record.retry == "fetch_result":
    return FETCH(record.invocation_id)

  if record.outcome == "not_attempted":
    if record.retry == "backoff":
      return RETRY_NEW_ATTEMPT
    return STOP_FOR_CORRECTION

  if record.outcome == "unknown":
    if operation.idempotent:
      return RETRY_NEW_ATTEMPT
    if record.retry == "same_idempotency_key" and
       operation.fingerprint == record.fingerprint:
      return RETRY_SAME_KEY
    return RECONCILE

  if record.outcome == "rejected" and record.retry == "backoff":
    return RETRY_WHEN_ALLOWED

  return RETURN_RECORDED_RESULT
```

Testez les transitions, pas les classes d'exception. Injectez un échec avant l'accès à l'identifiant, pendant TLS, avant la première écriture, après l'écriture complète de la requête, au milieu des en-têtes de réponse, au milieu d'un corps encadré, après la validation du résultat et pendant sa remise. Arrêtez le courtier entre chaque paire de transitions durables et vérifiez que la récupération n'affirme jamais `not_attempted` après `DISPATCH_STARTED`.

Ajoutez des comportements distants hostiles. Faites appliquer l'effet par un serveur, puis fermez sans réponse. Faites-lui renvoyer 500 après validation. Faites-lui respecter une clé d'idempotence, l'ignorer et refuser la même clé avec une charge modifiée. Pour SSH, fermez après l'acceptation de `exec`, omettez `exit-status` et envoyez un code de sortie avant de rompre le flux. Le résultat attendu doit toujours suivre les preuves.

Les métriques doivent compter séparément la phase et le résultat. Une hausse de `connection_setup/not_attempted` indique un problème de routage, de certificat ou d'authentification. Une hausse de `remote_execution/unknown` exige un rapprochement et peut révéler un problème de fiabilité distante. Les regrouper sous «échecs du courtier» masque la cause opérationnelle et le risque de duplication.

Ne laissez pas un SDK pratique effacer le contrat. S'il doit lever des exceptions, joignez l'enveloppe complète et limitez la relance automatique à `backoff` ou `same_idempotency_key`. L'appelant devrait devoir écrire un code manifestement dangereux pour répéter une action `unknown/reconcile`.

Les tests de récupération doivent inclure des workers concurrents et des baux expirés. Suspendez un worker après sa prise de bail de relance, laissez le bail expirer et lancez-en un autre. Lorsque le premier reprend, sa vérification de génération doit l'arrêter avant l'envoi. Sans ce contrôle, une classification parfaite peut encore créer des doublons concurrents.

Intégrez les budgets de relance au journal d'action, pas à des compteurs locaux du processus. Les redémarrages ne doivent réinitialiser ni le nombre de tentatives ni l'expiration de la clé distante. Lorsque le budget est épuisé, renvoyez les dernières preuves et exigez un rapprochement; remplacer le code par un banal «nombre maximal de tentatives dépassé» supprimerait le diagnostic le plus sûr.

L'état le plus difficile doit rester inconfortable. Lorsque le journal indique que l'envoi a commencé sans qu'une fin faisant autorité arrive, le courtier ne connaît pas le résultat distant. Conservez ce fait, rapprochez-le de l'état réel et refusez de transformer l'absence de preuve en autorisation.
