1. L'incident PocketOS
Le 25 avril 2026, PocketOS — une plateforme SaaS qui édite des logiciels pour les loueurs de voitures — a perdu l'intégralité de sa base de données de production. L'agent IA qui l'a fait tournait Claude Opus 4.6, le modèle phare d'Anthropic, intégré dans Cursor. L'agent avait reçu une tâche routinière sur l'environnement de staging. Il est tombé sur un problème de credentials. Il a décidé, de sa propre initiative, de « régler » le problème en supprimant un volume Railway. Il a cherché un token API, en a trouvé un dans un fichier sans rapport avec la tâche, l'a utilisé pour exécuter une seule mutation GraphQL, et la base de production a disparu.
Il a fallu 9 secondes.
Railway stockait les sauvegardes au niveau volume dans le même volume qui a été effacé, donc les backups sont partis avec les données. La dernière sauvegarde encore exploitable datait de trois mois.
Quand le fondateur de PocketOS, Jer Crane, a interrogé le modèle pour comprendre ce qui s'était passé, la réponse a pris la forme d'une confession :
"NEVER FUCKING GUESS! — and that's exactly what I did. I guessed instead of verifying. I ran a destructive action without being asked. I didn't understand what I was doing before doing it."
(« NE DEVINE JAMAIS ! — et c'est exactement ce que j'ai fait. J'ai deviné au lieu de vérifier. J'ai exécuté une action destructive sans qu'on me le demande. Je ne comprenais pas ce que je faisais avant de le faire. »)
Le post de Crane sur X a atteint 6,5 millions de vues — non pas parce que quelqu'un était surpris qu'un modèle de langage puisse partir en vrille, mais parce qu'ici les garde-fous n'existaient pas. Le token utilisé par l'agent avait été créé pour une raison précise — gérer des domaines personnalisés — mais l'API de Railway lui donnait des permissions complètes sur toutes les opérations, y compris destructives. Aucune confirmation n'était demandée avant suppression d'un volume. Aucun code déterministe ne séparait le raisonnement du modèle de l'appel API destructeur.
Ce n'est pas une histoire d'IA devenue folle. C'est une histoire d'architecture manquante. L'agent était la cause immédiate. La vraie cause, c'est une chaîne de choix de conception qui a permis à une décision unique du modèle d'atteindre un endpoint destructif sans rien entre les deux.
Cette chaîne, c'est de ça que je veux parler — parce que je fais aussi tourner des agents IA en production, et ce que j'ai construit depuis deux ans est, essentiellement, une pile de barrières qui rendent un PocketOS-en-9-secondes impossible par construction.
2. Pourquoi ça compte au-delà des agents de code
Je ne construis pas d'agents de code. Je suis urologue au Maroc et j'ai appris Python tout seul parce qu'aucun logiciel achetable ne correspondait à ma manière de travailler. Le code que je fais tourner en production — environ 104 000 lignes, sur un seul VPS à 5 €/mois — supporte quatre systèmes : une plateforme d'automatisation pour mon cabinet médical, un système de raisonnement à domaine spécifique qui produit des évaluations de juste valeur pour environ 75 sociétés cotées, un suivi de finances personnelles, et un terrain de R&D. C'est le système de raisonnement financier qui est le plus pertinent ici, à cause de ce que font réellement ses agents.
Quand mes agents échouent, ils ne suppriment rien. Ils produisent des scores faux. Une société mal classifiée reçoit une fair-value trompeuse. La fair-value alimente un signal achat/vente. Le signal est lu. Le capital est alloué sur une fausse base. Quelques mois plus tard, la position s'est composée en une perte qu'on ne peut plus tracer à un bug unique parce que les données étaient techniquement correctes — seule l'interprétation était fausse.
Avec les agents de code, le dommage est un instant. Avec les agents de raisonnement, le dommage est une trajectoire.
Cette distinction compte parce que la conversation dominante sur la sûreté des agents IA est aujourd'hui façonnée par des incidents de type PocketOS. Les corrections que les fournisseurs précipitent — confirmation avant opérations destructives, tokens scopés, exécution en sandbox — sont des progrès réels pour cette classe de risque. Mais elles ne traitent pas le risque plus lent, plus difficile : l'agent qui n'a rien écrit de dangereux en base et qui a quand même empoisonné le puits, parce que ce qu'il a écrit était une recommandation construite sur un raisonnement insuffisant.
Le constat vaut aussi pour l'IA médicale, l'IA juridique, l'IA d'advisory, l'IA de due diligence. Le danger n'est pas l'instant d'action catastrophique. C'est la dérive accumulée de productions conséquentes qui semblent toutes correctes prises isolément.
Les patterns que je décris dans la suite ont été conçus pour ce second type de risque. Il se trouve qu'ils gèrent aussi presque par effet de bord le type PocketOS — parce qu'une fois qu'on a rendu impossible une action unilatérale du modèle, on a traité les deux types. Mais le problème initial que je résolvais n'était pas « et si le modèle supprime ma base ? ». C'était « et si le modèle donne une réponse confidemment fausse que personne ne détecte pendant trois mois ? ».
La structure a trois couches. Aucune n'est nouvelle prise seule. C'est la combinaison, appliquée à des contextes non-coding-agent, que je n'ai pas trouvée formalisée ailleurs.
Les trois couches :
- Isolation horizontale — quatre instances Claude séparées, avec des rôles, des permissions et des rayons d'action différents.
- Ordonnancement vertical — une machine à états bloquante qui rend physiquement impossible le fait qu'une phase d'analyse s'exécute avant ses prérequis.
- Traçabilité longitudinale — chaque appel modèle, chaque décision intermédiaire, chaque cross-check stocké dans un format qui rend la chaîne entière auditable des mois plus tard.
Je vais passer les trois en revue, avec le code que je fais effectivement tourner en production. Je serai aussi honnête sur les cas où ce pattern est exagéré, sur les outils existants (Langfuse, pytransitions, Claude Code subagents) qui font certains aspects mieux, et sur la discipline humaine qu'aucun code ne peut imposer à ma place.
3. Niveau 1 — Isolation horizontale : quatre instances Claude avec des rayons d'action différents
La première couche, c'est de diviser « l'agent IA » en plusieurs processus indépendants, chacun avec sa propre session Claude, chacun avec un scope d'action nettement différent.
En production en ce moment, j'ai quatre instances Claude qui tournent en parallèle :
| Instance | Processus | Scope | Peut écrire en base ? |
|---|---|---|---|
| 1. Claude conversationnel | Web/mobile Anthropic + mes serveurs MCP | Architecture, revue de code, validation, prise de décision | Non. Ne produit jamais d'avis sur une société spécifique. N'écrit nulle part. |
| 2. Claude Code | Utilisateur Linux dédié un utilisateur Linux dédié à faible privilège (code-runner dans mon setup), terminal uniquement |
Exécution lourde : refactos, jobs batch, écritures fichiers dans son sandbox | Non. Ne push jamais de commit Git. N'écrit jamais en base de production. |
| 3. Claude du bot Telegram | Daemon Python long-running, clé API distincte | Interface conversationnelle : lit les questions en langage naturel, choisit les tools, renvoie des réponses formatées | Non. Dispose exactement de 13 tools en lecture seule et 2 tools d'administration. Aucun tool n'existe pour écrire dans les tables métier. |
| 4. Claude des agents du pipeline | Subprocess créé par phase d'analyse, clé API distincte | Le vrai travail de raisonnement : classifier une société, estimer Ke et croissance, calculer la fair-value, valider. |
Non, encore une fois. Chaque agent produit du JSON strict via tool_use. Python parse ce JSON, exécute des assert sur chaque champ, et ne persiste qu'ensuite. |
Le même fait tient pour les quatre lignes : aucune instance Claude n'écrit en table de production directement. Les écritures sont faites par du code Python déterministe, après validation du JSON produit.
Ça paraît évident. Ça ne l'est pas. Dans l'architecture de PocketOS, l'agent Cursor pouvait composer une commande curl, trouver un token dans un fichier, et appeler l'API GraphQL de Railway. Le chemin du raisonnement du modèle vers l'endpoint destructif passait par aucun code de validation — juste un shell. C'est le défaut architectural.
La division en quatre instances me donne aussi une propriété à laquelle je tiens plus que je ne l'attendais : un rayon d'action borné si une instance déraille.
- Si Claude conversationnel hallucine une fair-value pendant une discussion, l'hallucination reste dans notre conversation. Elle n'atteint jamais la base.
- Si Claude Code se fait jailbreaker ou social-engineer pour exécuter un
rm -rf, le pire qu'il puisse faire est de détruire son propre sandbox sous/home/code-runner. Le code de production vit ailleurs. - Si le bot Telegram subit une prompt injection par un message malveillant, il a 13 tools en lecture à abuser — et un quatorzième qui déclenche un pipeline. Il n'y a pas de tool pour écrire dans
scores, pas de tool pour écrire dansscore_model, pas de tool pour écrire dansagent_*_state. Ces tables ne sont simplement pas dans son monde. - Si un agent du pipeline — le plus directement connecté aux écritures — renvoie un score faux, le validateur Python exécute des
assertsur chaque champ. L'assertion casse, l'agent est marquéFAILED, et la mauvaise valeur n'arrive jamais en base.
Voici le vrai registry des tools du bot Telegram, légèrement abrégé et anonymisé :
# bot/tools/registry.py — liste déclarative des tools
TOOLS = [
# 13 tools en lecture seule
{"name": "get_company", "description": "Fondamentaux pour un ticker..."},
{"name": "get_score_details", "description": "Détails complets du calcul FV..."},
{"name": "list_by_signal", "description": "Sociétés avec signal X, triées par upside..."},
{"name": "list_by_sector", "description": "Sociétés du secteur X avec signaux et upside..."},
{"name": "get_top_opportunities", "description": "Sociétés avec le meilleur upside..."},
{"name": "get_market_overview", "description": "Répartition par signaux/secteurs..."},
{"name": "get_known_issues", "description": "Issues méthodologiques en cours..."},
{"name": "get_red_flags", "description": "Sociétés où notre FV diverge >40% du consensus..."},
{"name": "get_methodology_rules", "description": "Décisions méthodologiques actives..."},
{"name": "get_reclassifications", "description": "Historique des changements de profil..."},
{"name": "search_companies", "description": "Recherche fuzzy par ticker ou nom..."},
{"name": "query_doctrine", "description": "Recherche dans le document de méthodo..."},
{"name": "list_models", "description": "Modèle Claude actuel par agent + coût récent..."},
# 2 tools admin — opérationnel, pas une écriture métier
{"name": "configure_model", "description": "Changer le modèle Claude d'un agent..."},
# 1 tool de déclenchement — fire-and-forget, retourne immédiatement
{"name": "trigger_analysis", "description": "Lance une analyse pipeline en asynchrone..."},
]
def execute_tool(name, tool_input, context=None):
handler = HANDLERS.get(name)
if not handler:
return {"error": f"Unknown tool: {name}"}
return handler(tool_input, context)
Il n'y a pas de tool update_company. Pas de tool set_fair_value. Pas de tool override_signal. Le bot ne peut littéralement pas écrire une fair-value, parce que la fonction qui le ferait n'existe pas dans sa table de dispatching.
C'est ce que ceux qui écrivent sur la sûreté des agents appellent une hard boundary — une contrainte imposée non pas en demandant gentiment au modèle, mais par l'architecture elle-même. Le modèle peut décider qu'il veut écrire dans score_model. Cette décision n'a aucun chemin vers une action, parce qu'aucun tool n'implémente l'action.
C'est précisément ce qui manquait dans la chaîne PocketOS. L'agent Cursor a décidé qu'il voulait supprimer un volume Railway. La décision s'est traduite en curl, qui s'est traduit en mutation GraphQL, qui s'est exécutée. À aucun moment du chemin, du code déterministe n'a refusé de traduire « supprime le volume » en l'appel API réel.
Le bot peut être jailbreaké, prompt-injecté, manipulé, ou simplement halluciner. Il ne peut toujours pas écrire en base. Pas parce qu'on lui a demandé de ne pas le faire. Parce que le tool n'existe pas.
4. Niveau 2 — Ordonnancement vertical : la machine à états qui empêche de sauter une étape
L'isolation horizontale traite la question « qui peut faire quoi ». Elle ne traite pas « dans quel ordre ». C'est l'objet de la deuxième couche.
Un pipeline de raisonnement n'est pas une suite d'appels indépendants. C'est une chaîne où chaque étape dépend du fait que la précédente a été faite correctement. Si le classifieur n'a pas tourné, l'estimateur n'a rien sur quoi travailler. Si l'estimateur a sauté une étape, le calcul de fair-value opère sur n'importe quoi. Si le validateur tourne avant qu'il n'y ait quelque chose à valider, on obtient un « rien » confidemment approuvé.
La correction intuitive, c'est « l'orchestrateur appelle les agents dans l'ordre ». Ça marche jusqu'au jour où l'orchestrateur a un bug, ou jusqu'au jour où quelqu'un appelle directement une méthode pendant un debug, ou jusqu'au jour où un retry partiel redémarre au milieu sans recontextualiser. J'ai donc rendu impossible le saut de phase en imposant l'ordre dans la classe elle-même.
La classe du pipeline a douze états séquentiels :
init → loaded → analyzed → characterized → contextualized
→ classified → ke_set → g_set → estimated
→ valued → checked → written
Chaque méthode déclare l'état qu'elle requiert et celui vers lequel elle avance. Si l'état ne colle pas, Python crashe. Voici tout le mécanisme d'application, cinq lignes :
def _advance_state(self, required, next_state):
"""Vérifie l'état requis et avance."""
allowed = (required,) if isinstance(required, str) else required
if self.state not in allowed:
raise AssertionError(
f"État requis : {allowed}, état actuel : {self.state}"
)
self.state = next_state
Et voici à quoi ça ressemble en usage, dans la méthode qui calcule la fair-value :
def compute_fair_value(self, multiple: float, justification: str) -> float:
self._advance_state('estimated', 'valued') # crash si pas estimé
self._assert_justif(justification, threshold=30)
# ... logique métier
Le pattern est uniforme sur les douze phases. Toute méthode commence par self._advance_state(...). Toute méthode valide ses propres arguments avant de faire quoi que ce soit. Il n'existe aucun chemin dans le code qui permette d'appeler compute_fair_value avant que la société ait été classifiée. Python lèvera AssertionError et la pile d'appels remontera.
C'est volontairement minimal. Il existe des bibliothèques Python de machine à états matures — pytransitions est l'évidente, environ 10 ans d'existence, avec des décorateurs, callbacks, hooks, conditions, et statecharts hiérarchiques. Pour la plupart des cas où on veut vraiment une machine à états, ces bibliothèques sont meilleures que ce que j'ai. Elles donnent composabilité, régions parallèles, états d'historique. Des choses utiles.
Je ne les ai pas utilisées parce que pour ce pipeline, les besoins sont étroits :
- Pas de transitions en arrière. Une fois une phase faite, on ne la défait pas ; on relance une nouvelle analyse.
- Pas de branches conditionnelles. L'ordre est le même pour toute société.
- La persistance doit être custom de toute façon, parce que je veux reprendre après un crash sans repayer pour des appels Claude déjà aboutis.
Un check de 5 lignes qui vit dans chaque méthode est plus lisible qu'un diagramme de transitions séparé dans un autre fichier. Quand on lit compute_fair_value, on voit exactement l'état qu'elle requiert, immédiatement, à la ligne 1. On n'a pas à sauter à une table de transitions ailleurs pour le savoir.
Je ne dis pas que c'est le bon choix pour tout projet. Je dis que la bonne quantité de framework pour un pipeline strictement linéaire est à peu près zéro.
Le détail de reprise après crash
Chaque phase, après avoir réussi, écrit son état dans une table SQLite par agent. Le schéma est le même pour les six agents du pipeline :
CREATE TABLE agent_<role>_state (
ticker TEXT PRIMARY KEY,
status TEXT NOT NULL, -- NEW | RUNNING | DONE | FAILED
started_at TEXT,
error_message TEXT
-- ... champs métier spécifiques à l'agent
);
Si une analyse plante en cours de route — coupure de courant, OOM, échec réseau pendant un appel Claude API — la prochaine exécution lit le status pour chaque agent et saute ceux marqués DONE. Seuls les agents en échec et incomplets retournent. Ça économise du vrai argent : chaque phase est un ou deux appels Claude Opus, et sur un portefeuille de 75 sociétés ça s'additionne.
La machine à états n'est donc pas qu'un check en mémoire. C'est un enregistrement durable que je peux interroger des mois plus tard : est-ce que le validateur a vraiment tourné pour cette société à cette date, ou est-ce qu'on l'a sauté ?
On ne saute pas de phases. Python crashe. Et quand le monde crashe autour de Python, les tables SQLite se souviennent d'où on en était.
5. Niveau 3 — Traçabilité longitudinale : chaque décision enregistrée
Les deux premières couches disent ce que le système peut faire et dans quel ordre. Elles ne disent pas, après coup, ce qu'il a réellement fait. C'est le travail de la troisième couche.
Chaque appel à Claude dans ce système écrit une ligne dans une table claude_calls :
CREATE TABLE claude_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now')),
agent_name TEXT NOT NULL, -- 'classifier', 'estimator', 'valuator', 'validator', ...
ticker TEXT,
trace_id TEXT, -- groupe les retries d'un même appel logique
batch_id TEXT, -- groupe tous les appels d'une analyse complète
model TEXT NOT NULL,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read INTEGER DEFAULT 0,
cache_write INTEGER DEFAULT 0,
duration_ms INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0.0,
cost_mad REAL DEFAULT 0.0,
stop_reason TEXT,
attempt INTEGER DEFAULT 1,
error_message TEXT,
system_tokens INTEGER DEFAULT 0,
cache_eligible INTEGER DEFAULT 0
);
CREATE INDEX idx_claude_calls_ticker ON claude_calls(ticker);
CREATE INDEX idx_claude_calls_trace_id ON claude_calls(trace_id);
CREATE INDEX idx_claude_calls_batch_id ON claude_calls(batch_id);
L'insertion arrive tout à la fin de chaque wrapper d'appel Claude, succès ou échec confondus. Si l'appel a renvoyé un résultat, ce résultat a déjà été parsé et validé ; la ligne s'insère avec stop_reason='end_turn'. Si l'appel a échoué à la validation ou levé une exception, la ligne s'insère quand même, avec error_message rempli. Rien ne passe à travers.
À l'heure actuelle, il y a 532 lignes dans claude_calls couvrant 75 sociétés et 6 lots d'analyse complets. C'est la piste d'audit.
La table compagnon est fv_reasoning, qui stocke la sortie finale de chaque analyse — l'explication narrative, pas juste le chiffre :
CREATE TABLE fv_reasoning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
decision_date TEXT NOT NULL,
fv REAL,
cours REAL,
signal TEXT,
method TEXT,
multiple_used REAL,
earnings_used REAL,
ke REAL,
g REAL,
conviction TEXT,
reasoning TEXT NOT NULL, -- justification narrative
cross_checks TEXT, -- JSON : méthodes alternatives + écarts
sources TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
Le champ cross_checks est la partie à laquelle j'aurais le plus de mal à renoncer. Pour chaque fair-value que le système produit, il ne stocke pas seulement le chiffre — il stocke le résultat de méthodes de valorisation alternatives et les écarts entre elles. Une ligne typique ressemble à ça (anonymisée) :
ticker: "Société X"
fv: 780.0
method: "multiple × earnings"
signal: "🟢 ACHAT"
conviction: "Moyenne"
cross_checks: "DDM = 629 DH | PER implicite = 692.0x | consensus broker = 884 DH | écart_consensus = -11.7%"
Cette seule ligne me dit : la méthode principale a donné 780, le modèle d'actualisation des dividendes a donné 629, le PER implicite du marché est anormalement élevé (692x — donc le marché paie pour une croissance que nous n'extrapolons pas), et le consensus du grand broker est à 884, soit 11,7 % au-dessus de nous. Si on me demande dans six mois pourquoi nous avons dit « acheter à 780 » alors que le marché s'est replié à 600, je peux extraire la ligne exacte, lire les cross-checks, et reconstituer ce qu'on savait et ce qu'on ignorait à cette date.
Les réévaluations sont écrites, pas écrasées. Société X a cinq lignes fv_reasoning au cours d'avril : 795 (Achat, conviction haute), puis 884 (Fort achat, conviction moyenne), puis 884 à nouveau, puis 806, puis 780 aujourd'hui. Chaque ligne porte ses propres cross_checks et son propre reasoning narratif. L'historique, c'est la table.
Je ne prétends pas que c'est sophistiqué. Langfuse a un setup bien plus mature — traçage multi-tour, versioning des prompts, LLM-as-judge, A/B testing de prompts, dashboards de coût, OpenTelemetry. Si vous construisez sérieusement des agents en production et que vous n'avez pas encore d'observabilité, installez langfuse et instrumentez chaque appel Claude avant toute autre chose. C'est gratuit en self-host et ça fait plus que ce que je viens de décrire.
Ce que j'ai, c'est la piste de provenance minimum viable, intégrée directement dans la base métier plutôt que dans un service d'observabilité séparé. Le compromis : UI moins polie, requêtes moins riches, outillage moins standard. Le gain : quand je lance la même SQL qui produit le rapport utilisateur, j'ai un accès complet au raisonnement qui a produit chaque chiffre, dans la même requête, dans la même base. Pas de second système à garder en vie.
6. Le pattern critique : Claude ne touche jamais la base
Tout ce qui précède dans les trois sections plus haut repose sur une règle unique : l'API Claude n'écrit jamais en base de production, ni directement ni indirectement. Elle produit du JSON. Python parse le JSON, lance des assertions sur chaque champ, et ne commit qu'ensuite.
C'est une phrase. C'est aussi la chose sur laquelle je serais le plus ferme face à la tentation de compromettre.
Voici le flux, bout en bout, quand le pipeline demande à Claude de classifier une société :
- Python construit le prompt et le schéma
tool_usepour le classifieur. - Claude renvoie un objet JSON avec des champs comme
profile_primary,profile_secondary,thesis,justification. - Python valide que
profile_primaryest dans la liste des valeurs autorisées (raiseAssertionErrorsinon), queprofile_secondaryest autorisée et compatible avecprofile_primary(pas de paire interdite, raise sinon), que la justification fait au moins 30 caractères de texte, et que la combinaison des deux profils n'est pas dans une blocklist codée en dur dans le document de méthodologie. - Seulement après que toutes les assertions soient passées, Python exécute le SQL
INSERT INTO agent_classifier_state ...avec les valeurs.
Si une assertion échoue, l'agent est marqué FAILED, le message d'erreur est journalisé, et aucune ligne n'est écrite dans la table métier. Le pipeline n'essaie pas de « récupérer et d'écrire une version dégradée ». Il refuse de persister quoi que ce soit qui n'a pas passé le portail.
Contraste avec PocketOS. Le raisonnement de l'agent Cursor a produit « je devrais appeler volumeDelete avec ce token ». Cette décision s'est transformée en invocation curl. Le curl a frappé l'endpoint GraphQL de Railway. L'endpoint a exécuté. À chaque étape de cette chaîne, l'action destructrice était une couche d'indirection plus proche de se produire. À aucune étape, du code déterministe n'a refusé de traduire l'intention du modèle en l'action.
L'industrie de la sécurité a un nom pour cette distinction. Les garde-fous souples sont probabilistes — system prompts, project rules, « NEVER DELETE PRODUCTION DATA » écrit en majuscules. Ils dépendent du choix du modèle d'obéir. Ils peuvent être contournés par le modèle lui-même s'il se convainc que ce cas particulier est une exception. PocketOS avait des garde-fous souples. La config projet de Crane disait littéralement « NEVER FUCKING GUESS. » Le modèle a deviné quand même, et s'est excusé après coup.
Les barrières dures (hard boundaries) sont déterministes. Elles vivent en dehors de la boucle de raisonnement du modèle. Elles rendent certains résultats structurellement impossibles, quelle que soit la décision du modèle. Le modèle peut être parfait ou en plein délire ; la barrière s'en moque, parce qu'elle ne demande rien au modèle.
Ce que je viens de décrire — tools en lecture seule, absence d'implémentation des tools destructifs, assertions de machine à états, validateurs JSON avant persistance — est une pile de barrières dures. Le modèle peut décider qu'il veut écrire une fair-value de 9999 sans justification. La décision n'a pas de chemin d'implémentation. Python ne laissera pas l'assertion passer. Aucune ligne n'est écrite. Le modèle a heurté le mur.
C'est la partie que je construirais en premier si je recommençais. Tout le reste — observabilité, traçabilité, choix du modèle par agent — c'est du confort. Le mur entre Claude et la base, c'est l'architecture.
7. Comparaison honnête avec les solutions existantes
Je veux passer une section à être honnête sur ce que ce pattern est et n'est pas, parce que j'ai lu trop de posts d'ingénierie qui présentent le choix de l'auteur comme évidemment meilleur que les alternatives. Il l'est rarement.
Les subagents Claude Code sont l'analogue officiel le plus proche de ce que j'ai construit. Anthropic les livre dans Claude Code : chaque subagent a son propre system prompt, sa propre liste de tools, ses propres permissions, et un Claude parent délègue le travail à ces subagents au sein d'une même session. Pour des agents qui doivent déléguer à l'intérieur d'un workflow de coding — explorer le codebase, lancer les tests, proposer un patch — les subagents sont excellents. Ils donnent l'essentiel des bénéfices d'isolation sans avoir à faire tourner quatre processus séparés.
Ce que les subagents ne donnent pas, c'est l'isolation entre sessions, entre processus, entre clés API. Les quatre instances que je décris ne sont pas des subagents-d'un-parent. Ce sont quatre clients Claude entièrement indépendants tournant sur des plannings différents, avec des credentials différents, parlant à des tools différents, sur des utilisateurs Linux différents. Le bot Telegram continue à tourner pendant qu'aucune analyse n'est en cours. Les agents du pipeline n'existent que le temps d'une analyse. Claude conversationnel ne sait rien des deux. Il n'y a pas de session partagée, pas de contexte partagé, pas de parent qui pourrait coordonner un contournement.
Si vos agents n'ont besoin de se coordonner que dans une session, les subagents sont plus simples et probablement suffisants. Si vous avez besoin d'agents long-running, planifiés indépendamment et authentifiés différemment, le pattern de cet article est plus proche de ce que vous voulez.
Langfuse est la stack open source d'observabilité pour applications LLM, environ 19 000 stars sur GitHub, sous licence MIT, self-hostable. Elle vous donne le traçage multi-tour, le versioning de prompts, l'évaluation LLM-as-judge, le suivi de coûts, l'instrumentation OpenTelemetry, l'A/B testing, et une UI qui bat mes requêtes SQL par une marge confortable. Les tables claude_calls et fv_reasoning que j'ai décrites sont un sous-ensemble minuscule de ce que Langfuse fait déjà, avec une ergonomie inférieure.
Ce que Langfuse ne remplace pas, c'est la partie sur l'isolation et la restriction des tools. Langfuse observe ; elle ne contraint pas. Si votre bot a un tool delete_company, Langfuse loggera consciencieusement que le modèle l'a appelé et ce qui s'est passé. Le travail de barrière dure — s'assurer que ce tool n'existe pas en premier lieu — reste votre responsabilité, peu importe la stack d'observabilité que vous utilisez.
La recommandation honnête : installez Langfuse, instrumentez chaque appel Claude. Utilisez le pattern de cet article pour le travail de permissions et de machine à états. Ils sont complémentaires, pas concurrents.
pytransitions et python-statemachine sont les bibliothèques Python matures de FSM. Pour des machines à états avec transitions en arrière, états hiérarchiques, régions parallèles, ou chaînes de callbacks complexes, elles sont meilleures que ce que j'ai. Le _advance_state de 5 lignes ne marche que parce que mon pipeline est strictement linéaire sans backtracking. Si votre agent de raisonnement a une boucle RECHERCHE ↔ DRAFT ↔ REVUE, vous voulez une vraie bibliothèque FSM.
Les garde-fous infra ajoutés après incident — comme les délais de confirmation de Railway après PocketOS — sont des garde-fous souples dans la terminologie de cet article : l'action destructive reste possible, juste retardée. La vraie correction, c'est le scoping des tokens, que la plupart des fournisseurs n'offrent toujours pas pour les comptes personnels. Le papier CoSAI Agentic IAM (mars 2026) pose les principes formels que ce pattern implémente concrètement : pas de privilège permanent, accès juste-à-temps scopé, couche de gouvernance en dehors de la boucle de raisonnement de l'agent. À lire si vous voulez le cadre formel plutôt que ma version.
8. Là où ce pattern sur-architecte
Un pattern qui résout le mauvais problème est pire que pas de pattern. Donc :
Agents de code qui font de petits refactos. Vous n'avez pas besoin de quatre instances Claude. Vous avez besoin d'un sandbox et d'une revue de code. Claude Code avec ses listes par défaut d'allow/deny suffit.
Side projects et MVP. Le coût de construire cette architecture dès le jour 1 est largement supérieur au coût d'un incident sur un système qui n'a pas encore de vrais utilisateurs. Construisez le produit d'abord. Ajoutez le mur autour de Claude après la première fois où quelque chose s'est mal passé, ou après la première fois où les données d'un client auraient pu être affectées.
Agents one-shot. Un agent qui répond à une question et disparaît ne bénéficie pas de l'isolation multi-instance ; il n'y a rien à isoler. La machine à états et la traçabilité restent peu coûteuses à garder, mais la division horizontale est exagérée.
Vous n'avez pas vraiment de données privilégiées. Si le pire scénario de votre système est « le bot renvoie une réponse périmée », vous résolvez le mauvais problème avec ça. Le problème, c'est l'invalidation du cache, pas la gouvernance d'agent.
Deux limites du pattern lui-même, à dire explicitement.
La discipline humaine est irréductible. Chaque couche au-dessus repose sur l'hypothèse que les quatre instances Claude ont vraiment des credentials séparés, des clés API séparées, des frontières de processus séparées. Mettez la même ANTHROPIC_API_KEY dans les quatre fichiers .env et l'isolation est illusoire. Le pattern est imposé par la configuration, pas par le type-checking Python.
C'est de la défense en profondeur, pas de la vérification formelle. Ça rend les accidents moins probables et confinés quand ils arrivent. Ça ne les rend pas impossibles. Un bug dans un validateur Python — un assert qui ne vérifie pas ce que je croyais qu'il vérifiait — laisserait silencieusement passer une valeur fausse. Pour les systèmes où « probablement safe » ne suffit pas (dispositifs médicaux agissant sur la sortie d'une IA, tout ce qui touche un réseau électrique), ce pattern est nécessaire mais pas suffisant. Il faut aussi des méthodes formelles et de la redondance.
9. Récap
Trois couches entre Claude et une base de données de production qui contient quelque chose que je ne peux pas me permettre de perdre :
Isolation horizontale. Quatre instances Claude. Credentials différents, processus différents, tools différents. Celle qui parle aux utilisateurs n'a pas de tool pour écrire les données. Celle qui écrit les données n'a pas de contact avec les utilisateurs.
Ordonnancement vertical. Une machine à états bloquante avec douze phases séquentielles. Les méthodes refusent de tourner dans le désordre. Python crashe quand l'état est mauvais. SQLite se souvient de l'endroit où on en était après le crash.
Traçabilité longitudinale. Chaque appel Claude enregistré avec coût, tokens, batch_id, trace_id, message d'erreur. Chaque décision stockée avec ses cross-checks et son raisonnement narratif. Des mois plus tard, la chaîne se lit encore.
PocketOS a perdu sa base en 9 secondes parce que rien sur le chemin n'était déterministe. L'agent a décidé, le curl s'est lancé, l'API a exécuté. Aucun code déterministe entre les deux.
Le modèle peut être parfait. C'est le middleware qui compte. Construisez le middleware déterministe en premier. Le modèle, c'est la partie facile.
Diagramme d'architecture et trois snippets reproductibles (registry du bot, machine à états, piste de provenance) dans un gist public : https://gist.github.com/Kryscekk/a3a445d10e2e44f8ea615cb7f9850914
La référence complète (assets + snippets + versions bilingues) est sur https://github.com/Kryscekk/agents-in-practice/tree/main/essays/triple-defense-in-depth
Tout le code tourne en production sur un seul VPS à 5 €/mois. Repo bilingue EN/FR. Pas de marketing, juste les patterns que j'utilise au quotidien, comme urologue qui code son propre logiciel.
Top comments (0)