La dérive, et ce qui la retient
Ce que ça a coûté
ADVE-project/docs/governance/adr/0100-argos-hunter-backend-port.md, daté du 14 juin 2026, ouvre son contexte ainsi :
Audit (2026-06-14) : Argos n'était pas déployable — 0 % de backend.
Le même jour, Argos-studio contenait 184 fichiers : une API v1 complète — agencies, awards, brands, assets, markets, references, sectors, years, ingest/dossier — un schema.prisma, une migration 20260512171730_init, un seed, et une bibliothèque qualifiée réelle. Le tout posé en un commit le 15 mai, intouché depuis.
gh api 'repos/xtincell/Argos-studio/commits?until=2026-06-14T23:59:59Z&per_page=1'
Un mois d'écart. Un backend reconstruit parce que rien, nulle part, ne disait que l'autre existait. Le coût n'est pas le temps perdu : c'est qu'il y a désormais deux modèles de dossier de campagne à tenir d'accord, pour toujours.
Ce n'est pas un défaut de rigueur. C'est un défaut d'inventaire vivant, et documenter davantage ne le répare pas : une documentation écrite à la main dérive par construction.
Le remède était déjà écrit
galahad/engine/skills/audit-coherence.json :
Declared-vs-real consistency audit (read-only) […] The vaccine against the drift disease — what a component SAYS it is vs what it DOES.
Et galahad/engine/skills/README.md :
A skill is a declarative JSON contract that wraps a light model so it cannot drift. The deterministic shell does the work at zero token ; the model judges only at explicit
decidepoints […] This is the shape of the one component that never drifted (the patrol) — generalised.
Le mécanisme était inventé, éprouvé sur un composant, et formalisé. Il ne restait qu'à le porter à l'échelle de la flotte. audit-fleet est audit-coherence d'un cran au-dessus : le second surveille un agent contre son hôte, le premier surveille le programme entier contre GitHub.
1 · Le manifeste se recalcule
fleet.yml mélangeait deux natures qui n'ont pas la même durée de vie.
| Nature | Exemples | Durée de vie |
|---|---|---|
| Fait | volumétrie, topics, licence, branche, vitalité, racines, artefacts | périmée au prochain commit |
| Jugement | description, attention, manque, étage, ordre de construction | stable, rédigé par un humain |
Elles sont séparées :
fleet.yml— le jugement. Écrit à la main, jamais généré.fleet.lock.yml— les faits. Régénérés parscripts/releve-flotte.mjs.
make releve # régénère les faits depuis l'API GitHub
make derive # échoue si le fichier committé ne reflète plus la réalité
Le relevé ne mesure que du déterministe. La volumétrie est la somme des blobs de l'arbre, jamais repos.size : ce dernier est calculé en tâche de fond par GitHub et renvoie 0 sur un dépôt fraîchement créé, ce qui faisait diverger deux passes consécutives. Un chiffre qui bouge sans que rien n'ait bougé fabrique de fausses dérives, et une alarme qui crie au loup finit par ne plus être lue — c'est ainsi qu'un slo-check a produit 541 issues que personne n'a ouvertes.
La tension entre les deux fichiers est l'audit. Trois champs de fleet.yml ressemblent à des faits mais sont des jugements, et c'est leur démenti par le .lock qui constitue un signal :
Jugement dans fleet.yml | Démenti par fleet.lock.yml |
|---|---|
vitalite: actif | commits_90j: 0 |
licence: proprietaire | licence: ABSENTE |
deploiement: {racine: views} | racines: [.] |
2 · 15 contrôles, deux points d'exécution
scripts/signaux-flotte.mjs porte les contrôles. Chacun n'écrit rien quand tout va bien, et une ligne par anomalie sinon.
node scripts/signaux-flotte.mjs # tous
node scripts/signaux-flotte.mjs non-classe # un seul
node scripts/signaux-flotte.mjs --strict # sort en 1 s'il trouve (pour la CI)
Extraits de scripts/signaux-flotte.mjs — la liste vivante, pas une copie.
Chacun n'écrit rien quand tout va bien.
| Signal | Ce qu'il empêche |
|---|---|
non-classe | SHK-0001 — un dépôt du compte qui n'est ni dans la flotte, ni écarté nommément, n'existe pour personne. C'est ainsi que cinq outils ont vécu hors de tout manifeste jusqu'au 14 septembre 2026. |
absent-du-manifeste | SHK-0001 — le topic dit l'appartenance, fleet.yml dit le rôle. Un dépôt qui a le premier sans le second est entré dans la flotte sans être jugé. Le dépôt du programme lui-même est exempt : il porte le manifeste, il n'y figure pas. Encore faut-il qu'il se nomme — d'où programme:. |
licence-absente | SHK-0001 — fleet.yml déclare une licence choisie ; le relevé dit si le fichier existe. Douze dépôts sur treize n'en avaient aucun. |
vitalite-dementie | SHK-0001 — vitalite est un jugement. Le relevé peut le démentir, et c'est ce démenti qui est le signal : un composant dit actif sans un seul commit en quatre-vingt-dix jours ne l'est pas. |
racine-fantome | SHK-0001 — trois composants ont leur point d'entrée en sous-dossier. Une racine déclarée que le dépôt ne porte pas rend le composant indéployable pour qui suit le manifeste, sans qu'aucune erreur ne le dise. |
entree-fantome | SHK-0001 — un point d'entrée statique qui n'est pas dans le dépôt rend le composant inouvrable. Le relevé porte les racines, pas les fichiers : on vérifie donc que la racine existe, et que le mode statique en déclare une ou un fichier à la racine du dépôt. |
faits-dans-le-jugement | SHK-0001 — le jugement ne réénonce pas les faits. Un champ factuel qui réapparaît dans fleet.yml est une régression vers le manifeste écrit à la main, qui était vrai le jour du relevé et faux au commit suivant. |
etage-invalide | SHK-0001 — deux étages, et deux seulement. Un outil versé sans submodule sous tools/ est versé sur le papier uniquement. |
source-partagee | SHK-0002 — deux composants qui se partagent une source doivent le dire, et dire qui la possède. C'est le contrôle qui aurait épargné l'ADR-0100 : son auteur, contraint de déclarer qui possède Argos, aurait trouvé Argos-studio au lieu de reconstruire son backend un mois plus tard. Déclaratif et sans réseau, donc exécutable par la patrouille à coût nul. Une recherche de code dirait mieux, mais ne détecte qu'un symbole DÉJÀ dupliqué — celui-ci détecte l'intention de le faire. |
divergence-perimee | SHK-0003 — une affirmation de duplication sans chiffre est une opinion, et une opinion écrite dans un manifeste finit par être lue comme un fait. Ce manifeste a porté « le moteur existe en trois exemplaires, dette structurelle n°1 » : c'était faux, et un plan en a découlé. Toute déclaration de divergence porte donc sa mesure et sa date. Quand les deux dépôts se trouvent clonés côte à côte, le contrôle la REFAIT : un chiffre déclaré à plus de dix points de la réalité est une dérive, pas une approximation. Sinon il vérifie seulement qu'elle n'a pas plus de 90 jours. |
produit-inconnu | Re-mesure si les deux dépôts sont là : ROOT est le répertoire qui accueille make clone-all, soit le parent de shinkiro. / const ici = join(RACINE, "..", c.nom, d.chemin); const la = join(RACINE, "..", d.avec, d.chemin); if (existsSync(ici) && existsSync(la)) { try { const sortie = execFileSync(process.execPath, [join(RACINE, "scripts/mesure-divergence.mjs"), ici, la], { encoding: "utf8", timeout: 60000 }); const reel = Number(sortie.match(/^mesure_pct:\s(\d+)/m)?.[1]); if (Number.isFinite(reel) && Math.abs(reel - Number(d.mesure_pct)) > 10) return [${c.nom} — divergence déclarée ${d.mesure_pct} % avec ${d.avec}, mesurée ${reel} % à l'instant]; return []; } catch { / la mesure a échoué : on retombe sur le contrôle de fraîcheur / } } const jours = Math.floor((Date.now() - Date.parse(String(d.le))) / 864e5); return jours > 90 ? [${c.nom} — divergence avec ${d.avec} mesurée il y a ${jours} jours, jamais revérifiée] : []; }), /* Le manifeste et le portefeuille numérotaient les produits différemment — 00/04/05 d'un côté, 01 à 07 de l'autre — et les TROIS valeurs déclarées étaient fausses. Personne ne l'avait vu parce qu'aucun contrôle ne lisait les deux fichiers ensemble. Une dérive n'a pas besoin de deux dépôts pour exister : deux documents du même dépôt suffisent. |
derive-sans-canon | Les skills encodent une partie de la méthode et n'ont pas suivi le programme. Elles vivent hors de tout dépôt, donc hors de portée des onze autres signaux — l'audit n'inspecte que GitHub. Le seul contrôle possible est déclaratif : chaque dérivé nomme le composant qui fait foi, et ce composant existe. Un dérivé dont le canon a disparu est une copie devenue la seule source, sans que personne l'ait décidé. |
portail-perime | Le portail est généré depuis fleet.yml et fleet.lock.yml. S'il a été publié avant leur dernière modification, il montre un programme qui n'existe plus — et il le montre à qui vient regarder. Comparer des dates ne suffirait pas : le manifeste peut changer deux fois le même jour. On compare donc l'empreinte exacte que le portail a inscrite en se générant. |
adr-sans-signal | La série SHK ne survit que si l'audit la lit. Une ADR acceptée qui énonce une règle vérifiable nomme son signal ; sans quoi la décision est écrite et rien ne la défend. Une ADR peut déclarer « aucun » — c'est un choix, pas un oubli. |
releve-perime | Un signal cité mais inexistant est pire qu'aucun signal : l'ADR a l'air défendue et ne l'est pas. C'est la dérive sous sa forme la plus sournoise — un document qui affirme un contrôle absent. / / Un identifiant de signal se déclare en tête d'item de liste, comme le prescrit docs/adr/README.md — sans quoi le moindre mot entre accents graves passerait pour un contrôle. / const nommes = [...sect[1].matchAll(/^[-]\s+([a-z][a-z0-9-]+)/gm)].map((m) => m[1]); const inventes = nommes.filter((n) => !connus.includes(n)); if (inventes.length) return inventes.map((n) => ${f} — cite le signal « ${n} », qui n'existe pas dans signaux-flotte.mjs); if (nommes.length) return []; if (/\baucun\b/i.test(sect[1])) return []; return [${f} — Accepted, section présente mais aucun signal cité]; }); }, /* Le relevé n'est utile que frais. Au-delà de quinze jours, fleet.lock.yml redevient ce qu'il remplaçait : un instantané qui affirme. |
scripts/signaux-flotte.mjs
Deux points d'exécution, parce qu'ils voient deux réalités différentes :
- La patrouille galahad exécute la skill
audit-fleet, à coût nul, avecSHINKIRO_HOMEpointant sur une copie du programme. Elle voit le VPS. .github/workflows/fleet-drift.yml, chaque lundi. Il voit GitHub : il relève, compare, et tient une issue — titre stable, mise à jour sur place, fermée d'elle-même quand la flotte redevient conforme.
Le workflow a trois états, jamais deux : conforme, derive, et non-mesurable. Un contrôle qui ne peut pas mesurer n'est pas un contrôle qui passe : il échoue en rouge et le dit. C'est la leçon exacte des 541 fausses issues.
Prérequis — le relevé lit vingt-trois dépôts, en majorité privés.
GITHUB_TOKENne voit que le dépôt courant : le workflow a besoin d'un secretFLEET_TOKEN, jeton fine-grained sur tous les dépôts, permission Contents : Read-only et rien d'autre. Sans lui, il n'échoue pas en silence — il déclare qu'il n'a pas pu mesurer.Et un jeton partiel est traité comme aucun jeton. Un accès qui couvre certains dépôts et pas d'autres produirait un relevé amputé, et l'amputation se lirait comme une dérive — des composants qui « disparaissent » de la flotte. Le script compte donc ses échecs d'API, refuse d'écrire
fleet.lock.ymls'il y en a un seul, et sort en 3. Le workflow traduit ce 3 ennon-mesurable.Le même piège guette l'arbre vide : un dépôt inaccessible et un dépôt réellement vide rendent la même chose. Un arbre vide sur un dépôt non archivé est donc compté comme un échec, pas comme un fait.
Le dépôt du programme ne se relève pas lui-même
Un fichier ne peut pas enregistrer l'état du commit qui le contient. Écrire fleet.lock.yml change shinkiro — son nombre de fichiers, son poids, ses commits — ce qui périme à l'instant même la ligne shinkiro du fichier qu'on vient d'écrire. Le relevé ne serait jamais vert.
SHK-0001 le disait déjà pour fleet.yml : le programme porte le manifeste, il n'y figure pas. La raison vaut doublement pour le relevé, et pour une raison que ce dispositif connaît bien — une alarme qui sonne toujours cesse d'être lue.
Le défaut n'a pas été trouvé par relecture : il l'a été au premier passage réel du workflow, qui a signalé shinkiro passant de 6 à 21 fichiers en fusionnant sa propre PR. Le constat était juste ; c'est sa répétition garantie qui était le défaut.
Un workflow planifié ne tourne que depuis la branche par défaut
Tant que fleet-drift.yml n'est pas sur main, GitHub Actions ne le voit pas : gh workflow list ne le liste pas, gh workflow run ne le trouve pas, et le cron ne se déclenche jamais. Aucune erreur nulle part — le contrôle est simplement absent. C'est la forme la plus silencieuse de la maladie : un dispositif anti-dérive qui a lui-même dérivé en n'étant jamais installé.
3 · Les décisions, adossées aux contrôles
La série SHK- vit dans docs/adr/ et n'arbitre qu'entre composants — les décisions internes restent chez eux. Chaque ADR Accepted qui pose une règle vérifiable nomme le signal qui la défend, et ce signal existe : adr-sans-signal rejette aussi bien l'ADR sans contrôle que celle qui en cite un imaginaire.
Une ADR peut légitimement déclarer Aucun. C'est une déclaration, pas un oubli.
Contrôler le contrôleur
make releve && git diff --exit-code fleet.lock.yml # relevé reproductible
node scripts/signaux-flotte.mjs # « COHÉRENT — aucune dérive »
Puis laisser passer un cycle complet sans intervenir, et lire l'issue.
- Vide : le dispositif tient.
- Des dérives réelles : il fonctionne.
- Du bruit : c'est lui qui a dérivé. Le corriger avant de s'y fier — sinon on reproduit précisément ce qu'il est censé empêcher.
docs/DERIVE.md