← Tous les articles

Intentional engineering : écrire l'intention, faire vérifier le reste par la machine

Nous avons tous vécu ce moment un peu inconfortable en travaillant avec des agents. Une pull request de 400 lignes s’ouvre, les tests sont au vert, le code tourne, et pourtant nous sommes incapables d’expliquer ce qui a été produit. Non pas de dire si c’est juste, les tests s’en chargent, mais de dire pourquoi ce découpage-là, ce nommage-là, cette dépendance-là plutôt qu’une autre.

Pour nommer cette façon de travailler, le vocabulaire ambiant propose vibe coding. Le terme est devenu l’étiquette par défaut du développement assisté par IA, et je ne m’y reconnais pas. Non pas parce qu’il serait péjoratif, mais parce qu’il laisse entendre qu’utiliser des agents revient à abandonner une partie de l’intention derrière ce qu’on produit. De mon côté, c’est plutôt l’inverse. J’utilise des LLM du matin au soir pour concevoir, prototyper, arbitrer et implémenter, et je n’ai probablement jamais autant explicité mes choix et mes contraintes.

Le terme dans lequel je me retrouve davantage, et dont j’ai entendu parler récemment, est l’intentional engineering. Huit mois, 42 repos et près de 570 000 lignes ajoutées plus tard, voici ce que je mets derrière, et comment ça se décline sur deux projets réels.

Le problème n’est pas le niveau du modèle

On explique souvent les déboires avec les agents par leur niveau technique. C’était une bonne explication il y a deux ans, elle l’est nettement moins aujourd’hui. La difficulté s’est déplacée, et elle est structurelle : un LLM est une fonction non déterministe.

Posez deux fois la même question d’architecture, vous obtenez deux réponses différentes. Aucune des deux n’est fausse, elles sont l’une et l’autre plausibles et défendables, et rien ne garantit qu’elles soient compatibles entre elles. Sur un prototype, cette variabilité est une bonne chose, c’est même ce que vous venez chercher quand vous explorez. Sur un système que plusieurs personnes vont maintenir pendant deux ans, elle coûte cher.

Le réflexe le plus courant consiste à réduire ce non-déterminisme en ajoutant des contraintes : cadrer le prompt, détailler la demande, prendre un modèle plus performant, baisser la température. L’approche aide, mais elle atteint vite ses limites, parce qu’elle revient à attendre d’un générateur probabiliste qu’il se comporte comme une source de vérité.

L’intentional engineering prend la question par l’autre bout. Plutôt que de rendre le modèle déterministe, on installe du déterminisme autour de lui. Encore faut-il s’entendre sur ce que le mot recouvre, parce qu’il est souvent employé comme un synonyme vague de « sérieux ».

Ce que « déterministe » veut dire ici

Un programme est écrit dans un langage formel. Sa grammaire est finie et non ambiguë, et sa sémantique est fixée par la définition du langage. La même entrée y produit donc la même sortie, une fois mises de côté les sources d’indéterminisme explicites que sont l’horloge, le réseau ou l’ordonnancement des threads. Cette propriété est garantie par construction, pas constatée empiriquement.

Mais l’exécution n’est pas le point le plus intéressant. Ces mêmes langages formels rendent surtout la vérification mécanisable. Un système de types usuel est une procédure de décision : donnez-lui un programme, il termine, et il rend le même verdict sur le même code. Tous ne le garantissent pas, les templates C++ étant Turing-complets, le compilateur y bute sur une limite de récursion plutôt que sur un verdict. Mais dans le cas courant, à toolchain et lockfile figés, cargo build sur un commit donné répond la même chose aujourd’hui et dans six mois, sur ma machine et sur la CI. Certains systèmes de types vont plus loin : pour tout le code safe, le borrow checker de Rust établit avant l’exécution l’absence d’une classe entière d’erreurs, les data races et les use-after-free.

À l’extrême, les assistants de preuve poussent cette logique jusqu’au bout. On y énonce une propriété, et la machine vérifie la preuve que le programme la satisfait. Le compilateur C CompCert est écrit et prouvé en Rocq (anciennement Coq), le noyau seL4 l’a été en Isabelle/HOL. Le coût est hors de portée d’un projet ordinaire, mais la direction est instructive : la propriété est énoncée par un humain, sa vérification est entièrement mécanique.

Un LLM ne dispose d’aucune de ces propriétés. Il échantillonne une distribution de probabilité sur des tokens. Une température à zéro donne au mieux de la reproductibilité à conditions identiques (mêmes poids, même noyau de calcul, même batch), et la reproductibilité n’est pas de la vérification : obtenir deux fois la même sortie ne garantit pas qu’elle satisfasse quoi que ce soit. Il n’existe pas de verdict, seulement une sortie plausible.

L’asymétrie est là, et c’est elle qu’on exploite. Générer une architecture ne peut pas être une procédure de décision, alors que vérifier qu’un code respecte une règle donnée le peut souvent. Un type checker, une règle de lint, un validateur de schéma, un test exécuté sur les données réelles ont tous la même nature : ils terminent, et ils rendent un verdict reproductible sur l’artefact.

Ces deux extrémités encadrent un spectre sur lequel chaque projet se place. La relecture humaine en occupe le bas : elle n’est ni totale ni reproductible, et elle dépend de l’attention encore disponible un mardi à 18 h. L’assistant de preuve en occupe le haut, à un coût que presque aucun projet ne peut financer. Entre les deux, la question est la même pour chaque propriété à laquelle je tiens : puis-je l’écrire sous une forme qu’une machine sait vérifier ? Une règle de nommage devient une règle de lint, une contrainte de dépendance devient une règle de build, un invariant sur les données devient un test. Ce qui résiste à cette traduction reste à ma charge.

La définition, en deux règles

  1. L’intention vit en dehors du modèle, dans des artefacts versionnés, relus et opposables : specs, ADR, LLD, schémas, conventions.
  2. Ce qui doit être vrai est vérifié par une procédure de décision, pas par la relecture humaine.

Entre ces deux bornes, l’agent dispose d’une grande liberté, et c’est voulu. Le problème n’apparaît que lorsque les bornes manquent : sans intention écrite en amont ni vérification mécanique en aval, rien ne distingue une bonne décision d’une décision plausible.

flowchart LR
    subgraph det1["Déterministe : ce que j'écris"]
        Intent["SPEC / ADR / LLD<br/>schémas, conventions"]
    end
    subgraph nondet["Non déterministe : ce que je délègue"]
        Agent["Agent + LLM<br/>conception, code, docs"]
    end
    subgraph det2["Déterministe : ce qui tranche"]
        Gates["Hooks, CI, lint, tests<br/>schémas, supply chain"]
    end
    Intent --> Agent --> Gates --> Artifact["Artefact livrable"]
    Gates -.->|"échec : l'agent corrige le code"| Agent
    Gates -.->|"écart répété : je corrige l'intention"| Intent

La boucle de feedback est tout aussi importante que le reste, et elle ne peut pas se refermer sans intervention humaine. Lorsqu’un contrôle échoue, l’agent corrige le code ; de mon côté, je corrige l’intention qui a permis à l’écart de se produire. Un échec récurrent relève rarement d’une simple maladresse de l’agent, il révèle le plus souvent une intention insuffisamment précise. Mon rôle ne disparaît donc pas, il change de niveau : je passe moins de temps à relire des lignes et davantage à écrire les décisions et à régler les contrôles.

1. Écrire l’intention avant le code

Chez mon client actuel, une plateforme d’infrastructure d’entreprise, le corpus d’architecture compte 85 ADR, 9 LLD et un HLD, construits depuis février 2026. Sur un projet open source que je maintiens, une API gateway écrite en Rust, ce sont 8 specs et 31 ADR pour 92 500 lignes de code.

Dans les deux cas, l’ordre est le même : la décision est écrite avant que l’agent ne produise quoi que ce soit.

Le point non évident tient à la forme du template. Celui que nous utilisons chez le client impose une section que beaucoup d’équipes sautent :

## Options considered

### Option 1: Option name
| Advantages | Disadvantages |
| ---------- | ------------- |

### Option 2: Option name
...

## Decision
> **Decision**: Option X - Option name
**Justification**: ...

## Consequences
### Positive / ### Negative / ### Risks

Cette section est ce qui rend le template utile avec un agent, pour une raison simple : un agent à qui vous demandez d’implémenter va implémenter. Il ne s’arrêtera pas pour signaler qu’il existait trois autres approches. Et si vous lui demandez de justifier son choix après coup, il justifiera brillamment celui qu’il a déjà fait. Obliger les alternatives à exister sous forme de texte, avant la décision, est le seul moyen que j’aie trouvé de garder l’arbitrage discutable. C’est aussi la seule partie que je relis vraiment ligne à ligne.

Le template produit un second effet : les décisions se chaînent. Un ADR récent sur le DNS as code s’ouvre en citant les deux décisions dont il hérite, et délimite exactement ce qui restait ouvert :

ADR-049 decides the DNS server […]. It does not decide where records are authored, nor how they reach the server. This ADR does.

Ce paragraphe ne sert pas à documenter après coup. C’est ce qu’on donne à lire à l’agent avant qu’il ne commence : il tient dans une fenêtre de contexte, il est sans ambiguïté, et il évite qu’un agent rouvre six mois plus tard un débat déjà tranché.

2. Rendre les conventions lisibles par la machine

Une convention qui ne vit que dans la tête de son auteur ne sera pas appliquée par un agent, tout simplement parce qu’il n’y a pas accès. Il faut donc l’écrire à un endroit qu’il relit à chaque session.

Chez mon client, un fichier .agent/conventions.json décrit la mécanique documentaire :

{
  "role": "design",
  "branch_pattern": "docs/{slug}",
  "commit_convention": "conventional",
  "docs": { "check_cmd": "npm run build" },
  "adr": {
    "dir": "docs/business-it/architecture/adr",
    "filename_pattern": "{nnn}-{kebab}.md",
    "template": "docs/business-it/architecture/adr/000-template.md"
  },
  "lld": { "dir": "...", "template": "..." }
}

Sur la gateway, l’équivalent côté code est un CLAUDE.md qui descend jusqu’au niveau de l’idiome :

- Never `unwrap()` or `panic!()` in production code
- Use `.expect("reason")` only for provably infallible operations
- Use `parking_lot::Mutex`/`RwLock` over `std::sync` (no poisoning = no unwrap needed)
- Prefer `.is_some_and()` over `.map(...).unwrap_or(false)`

Le champ qui compte le plus dans ce fichier est check_cmd. Il ne décrit pas une convention, il donne à l’agent le moyen de vérifier son propre travail sans moi, ce qui le fait basculer du statut de consigne à celui de procédure de décision.

Il faut être précis sur ce que ces fichiers font et ne font pas, parce que c’est l’endroit où la méthode se raconte volontiers plus belle qu’elle n’est. Ils n’encodent pas l’intention du système. Ils encodent des garde-fous locaux sur la manière d’écrire le code. Si check_cmd lance cargo fmt, clippy et les tests unitaires, un agent peut produire un code parfaitement propre au regard de toutes ces règles tout en ayant pris une mauvaise décision métier ou architecturale.

Les deux niveaux sont complémentaires, et les confondre est l’erreur classique. L’ADR porte la décision d’architecture et reste lu par un humain. Le fichier de conventions porte ce qui se vérifie sans moi, et cesse d’occuper la revue. Chaque règle que je parviens à écrire dans le second libère du temps de relecture pour le premier, et c’est le seul arbitrage qui m’intéresse ici.

3. Rendre les contrôles bloquants

Un contrôle ne devient une procédure de décision qu’à partir du moment où il refuse. Tant qu’il se contente d’afficher un avertissement, il reste une information parmi d’autres, et il sera arbitré au cas par cas, donc par un humain fatigué, donc de façon non reproductible.

Sur la gateway, les contrôles s’empilent sur trois niveaux :

NiveauQuandContenu
Hook pre-commitavant chaque commitcargo fmt --check, cargo clippy -- -D warnings
CIsur chaque PR16 jobs, parmi lesquels fmt, clippy, MSRV, audit, build, tests unitaires, control plane, plugins, intégration, couverture, suite sécurité, benchmarks, UI, e2e
Planifiéen continucargo deny check (advisories, licences, bans, sources), signature keyless cosign + SBOM syft

Le -D warnings du hook change la nature du contrôle, puisqu’il transforme un avis de clippy en refus de commit. L’agent ne peut pas produire de commit que clippy désapprouve, il corrige donc lui-même avant que je voie quoi que ce soit. Une classe entière de remarques de revue disparaît ainsi de mon champ, non pas parce qu’on l’ignore, mais parce qu’elle est traitée par une procédure qui ne se fatigue pas.

Côté client, la même logique s’applique à de la prose. markdownlint et prettier tournent via husky et lint-staged, et le build Docusaurus sert de vérification finale. Un lien mort dans un ADR casse la CI au même titre qu’un unwrap() casse le build.

4. Faire porter la vérification sur l’intention elle-même

Un lint, un test unitaire ou un type checker vérifient la forme du code, sa syntaxe, son style, ses idiomes. Aucun ne dit si la décision d’architecture qu’il met en œuvre est la bonne. Sur les deux projets, cette décision est pourtant écrite dans un format que la machine sait vérifier, et ça change la portée du contrôle.

Sur la gateway, la décision fondatrice (ADR-0004) pose que les specs OpenAPI et AsyncAPI constituent la seule configuration, sans DSL propriétaire. La spec ne documente pas la gateway, elle la définit, et elle est compilée en artefact. La validation est stricte par défaut, une requête non conforme étant rejetée en 400. Ce n’est plus une question de style de code : vérifier la conformité d’une requête revient à vérifier la décision d’architecture elle-même.

Toujours chez le client, le repo netbox-data applique le même principe à l’infrastructure. Des fichiers YAML sont la surface d’écriture, OpenTofu réconcilie NetBox à partir d’eux, un plan s’affiche sur chaque PR, et un job planifié fait de la drift detection en ouvrant une issue dès que quelqu’un a modifié NetBox à la main plutôt que par le dépôt.

Le plus instructif est la suite de tests qui accompagne ces YAML. Son docstring explique ce qu’elle cherche :

These tests catch the kinds of bugs that mocked unit tests can’t: unquoted # comments dropping data […], duplicate slugs, references to undefined manufacturers.

Le cas du # mérite un mot, parce qu’il illustre bien la limite de la relecture. En YAML, un # précédé d’un espace dans une valeur non quotée ouvre un commentaire, et tout ce qui suit sur la ligne est ignoré à l’analyse. Le fichier reste valide, la donnée est simplement perdue. Une relecture humaine peut attraper ce genre de chose, mais pas de façon fiable et pas à chaque fois. Un test qui parse les fichiers réels le détecte systématiquement.

Tout ne s’écrit pas sous cette forme. Aucun schéma ne capture le choix entre deux découpages de service également valides, ni le fait qu’une décision techniquement correcte soit mauvaise pour l’équipe qui l’exploitera. Ces arbitrages restent à ma charge, et je les relis dans les ADR plutôt que dans le code.

Ce que ça coûte

La méthode a un coût, et il vaut mieux l’annoncer.

  • Écrire un ADR prend plus de temps qu’écrire le code correspondant. Ça reste rentable parce que le code sera réécrit et pas la décision, mais sur un script de 200 lignes c’est du gaspillage.
  • Un corpus se maintient. À 85 ADR, certaines décisions en remplacent d’autres. Sans discipline sur les champs Status et Superseded by, on ne documente plus une architecture, on empile son historique. C’est un travail réel, peu gratifiant, et qui demande beaucoup de rigueur.
  • Les contrôles ont un prix. 16 jobs de CI, ce sont des minutes sur chaque PR, des caches à entretenir et des tests instables à traquer. Un contrôle trop lent finit par être contourné, avec un --no-verify ou un job repassé en non bloquant.
  • Un contrôle strict a besoin d’échappatoires documentées. Le deny.toml de la gateway ignore explicitement deux advisories, chacune avec sa justification en commentaire (dépendance transitive, pas de montée de version sûre). Sans cette soupape, la première urgence transforme le contrôle strict en contrôle désactivé.
  • Le seuil de pertinence est réel. Ma règle empirique : plusieurs intervenants, en comptant les agents, ou une durée de vie supérieure à quelques mois. En dessous, il vaut mieux prototyper librement.

Par où commencer

L’ordre ci-dessous est celui du retour sur investissement.

  1. Un template d’ADR, et un premier ADR qui décide que vous utiliserez des ADR. Cinq minutes.
  2. Un fichier de conventions lisible par l’agent (CLAUDE.md, .agent/conventions.json), contenant avant tout la commande de vérification.
  3. Des contrôles bloquants, aussi stricts que l’équipe peut le supporter : hook pre-commit, qualité de code, seuil de couverture de tests.
  4. Ensuite seulement, déléguer largement, sans jamais quitter la boucle.

Le non-déterminisme d’un LLM est ce qui le rend utile : il produit des options auxquelles je n’aurais pas pensé seul. Il devient un risque au moment précis où il atteint l’artefact final sans qu’aucune procédure de décision ne se soit interposée. Le travail consiste à en placer une, puis à lui confier patiemment une part de plus en plus grande de ce que je relisais moi-même.