8 min de lecture

Les erreurs du courtier de secrets exigent des preuves

Concevez les erreurs du courtier de secrets autour des preuves d'exécution pour séparer les relances sûres des résultats distants inconnus.

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:

{"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

Vérifiez la trace hors ligne
sp audit verify contrôle la chaîne de hachage chiffrée sans clé du coffre.

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

Lancez SSH sans remettre les clés
L'assistant sans état sp-ssh exécute les commandes tandis que les clés privées restent chiffrées.

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

Journalisez chaque appel distant
Le journal d'activité conserve chaque appel et laisse une trace durable aux opérations incertaines.

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:

{"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:

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.

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.

FAQ

Un timeout peut-il prouver qu'une action distante n'a pas été exécutée?

Un timeout prouve seulement qu'un délai a expiré. Il constitue un échec préalable uniquement si les journaux durables montrent que l'envoi n'a jamais commencé; après l'envoi, gardez le résultat inconnu jusqu'à ce qu'une autre preuve le tranche.

Le courtier doit-il relancer chaque requête GET échouée?

Non. RFC 9110 définit GET comme idempotente selon sa sémantique attendue, mais un endpoint mal conçu peut encore produire des effets. Ne relancez que si le contrat de l'endpoint et les preuves du courtier le permettent.

Peut-on relancer sans risque une réponse HTTP 500?

Pas d'après le seul statut. Un 500 complet est un résultat distant connu, mais l'application peut avoir validé un changement avant de le générer; la sémantique de la méthode ou la déduplication distante doit donc justifier la relance.

Une clé d'idempotence empêche-t-elle toujours les doublons?

Non. Le service distant doit reconnaître la clé, la lier à l'empreinte de la requête et renvoyer le résultat conservé lors d'une répétition. Une clé présente uniquement dans le journal du courtier permet une corrélation, pas une déduplication.

Quelle est la réponse la plus sûre à un résultat distant inconnu?

Interrogez d'abord l'état distant avec un identifiant d'opération stable. Répétez seulement si le rapprochement ne montre aucun effet, si l'opération est idempotente ou si un contrat distant vérifié accepte la même clé et la même empreinte.

Pourquoi séparer la connexion de l'exécution distante?

Cette séparation indique si une requête applicative a pu atteindre l'autre côté. Un échec TLS sur une nouvelle connexion peut prouver l'absence de tentative, tandis qu'une coupure après écriture sur une connexion réutilisée peut laisser le résultat inconnu.

Comment signaler un échec pendant la remise du résultat?

Enregistrez phase=result_delivery et conservez le résultat distant déjà tranché. L'appelant doit récupérer le résultat stocké avec l'identifiant d'invocation au lieu de soumettre l'action une seconde fois.

La fermeture d'un canal SSH prouve-t-elle le succès de la commande?

Pas à elle seule. RFC 4254 recommande un code de sortie sans l'exiger, donc une fermeture sans code, signal ou marqueur applicatif peut laisser le résultat inconnu.

Quelles preuves inclure dans une erreur du courtier de secrets?

Incluez l'identifiant d'invocation, la phase, le résultat, la consigne de relance, l'état d'envoi, l'empreinte, les marqueurs de fin et l'identifiant du résultat s'il existe. Excluez les identifiants secrets et les données distantes sensibles non filtrées.

Comment une équipe peut-elle tester la sûreté des relances?

Arrêtez le courtier à chaque transition durable et injectez des pannes avant l'envoi, pendant l'écriture et l'encadrement, après la validation du résultat et pendant la remise. Vérifiez qu'un journal envoyé ne redevient jamais non tenté.

Sallyport

Sallyport exécute les appels d'API et les commandes SSH à la place de votre agent IA. Les clés restent dans un coffre-fort local sur votre Mac ; vous approuvez chaque exécution et chaque action est consignée dans un journal scellé.

© 2026 Sallyport · Open source sous Apache-2.0 · Oleg Sotnikov