runbooks : la recette tranche, le registre ne se compare plus a lui-meme

Un outil tiers veut n'offrir de ce moteur que ce qui n'agit pas. Le seul champ
qui le lui dise est `nature`. Il fallait donc qu'il ne mente pas, et il mentait
dans les deux sens.

`filiation` → `emancipation-prouver` se declarait « mesure » en portant
`fixes: {CONFIRMER: "true"}`. La cible tranche elle-meme — son refus dit
« cette preuve COUPE l'amont quelques secondes pour mesurer ». Un constat
rapporte ne rend pas inerte le geste qui l'obtient : l'etape passe a
`ecriture`.

Dans l'autre sens, trois cibles — flotte-creer, deployer-tout, reconstruire —
refusent sans confirmation sans que le registre le declare : un assistant les
lancait telles quelles, et sortait en 2. Comparer `nature` a `fixes` ne les
voyait pas, car c'est comparer deux champs ecrits par la meme main. `verifier`
lit desormais la RECETTE, qui ne ment pas.

Effet de bord traite : une etape qui agit barre celles qui la suivent dans la
console, alors qu'une « mesure » se debloque d'office. `depots-perimes` est
marque `facultative` — il nettoie ce que la filiation a laisse, il n'en est pas
le prealable.

`lister --json` rend le registre assemble d'un bloc, et le drapeau est refuse
hors de son action : rendre la prose humaine a qui demande du JSON est pire
qu'un refus. L'affichage humain ne bouge pas, et la preuve mesure ce qu'il
PORTE — un identifiant, un libelle, les marques de nature — et non seulement
qu'il n'est pas du JSON.

Valide : `verifier` a 0 ecart, test_runbooks.py 25/25, et sept des neuf
epreuves de `make test` ; test_raser et test_raser_resultat echouent sur
« Aucune instance montee », avant comme apres. Mesure : 127 etapes, dont 84 de
nature « mesure » — la surface qui n'agit pas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbXr5vv4Rat6PCPGG9GoG8
This commit is contained in:
Mathieu Benoit 2026-09-25 17:15:03 -04:00
parent d461492623
commit 1d07a6cf80
4 changed files with 303 additions and 3 deletions

View file

@ -1,5 +1,64 @@
# CHANGELOG — Set-OPS
## 2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont
**21 preuves sur `test_runbooks.py`, `verifier` à 0 écart.** Un outil tiers veut
n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est
`nature`. Il fallait donc qu'il ne mente pas — et il mentait une fois.
### Une mesure qui demande confirmation n'en est pas une
`filiation → emancipation-prouver` se déclarait `nature: mesure` et portait
`fixes: {CONFIRMER: "true"}`. Les deux ne peuvent pas être vrais ensemble : la
cible refuse sans confirmation, et son propre refus dit pourquoi — *« cette
preuve COUPE l'amont quelques secondes pour mesurer »*. La coupure est retirée
quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer.
Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas
inerte le geste qui l'obtient. L'étape est désormais `nature: ecriture`, et son
`pourquoi` dit ce qu'elle coupe.
`verifier()` refuse maintenant cette contradiction : rien ne la voyait, et
chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un
écart, exactement celui-là.
### Lire le registre sans analyser un arbre fait pour l'œil
`runbooks.py lister --json` rend le registre assemblé d'un bloc. Sans lui, un
outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive
au premier changement de mise en page — et importer ce module, ce qui le lie à
nos noms internes. Les deux se paient plus tard.
L'affichage humain ne change pas, et une preuve le tient : un `lister` qui
rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que
personne ne le voie.
### La recette tranche, le registre ne se compare plus à lui-même
Le premier invariant comparait `nature` à `fixes` — deux champs écrits par la
même main. Le même mensonge repassait en EFFAÇANT la ligne `fixes`, et trois
cibles le portaient ainsi : `flotte-creer`, `deployer-tout`, `reconstruire`
refusent sans confirmation, le registre ne le déclarait pas, et un assistant
les lançait telles quelles — sortie en 2. Le contrôle lit désormais la
RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant
rencontre.
### Une étape qui agit barre celles qui la suivent
Passer `emancipation-prouver` à `ecriture` l'a placée derrière un ménage
destructif : la console débloque d'office une étape « mesure », mais fait
attendre toute autre que la précédente ait réussi dans la session. Un ménage
qu'on peut n'avoir rien à faire rendait donc la preuve injouable.
`depots-perimes` est marqué `facultative` — il nettoie ce que la filiation a
laissé, il n'est pas son préalable.
Mesuré sur le registre réel : 17 runbooks, 127 étapes. **84 sont de nature
`mesure`** — c'est la surface qui n'agit pas, et la seule qu'un outil puisse
offrir sans confirmation. Les 110 dont les `fixes` ne portent pas de
`CONFIRMER` en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » :
compter sur ce chiffre pour dire « sûr » serait une erreur de lecture.
## 2026-09-20 (12) — Une décision appliquée à moitié se croit tenue
**83 preuves, `make test` à 0 échec.** L'exploitant a demandé : « n'avions-nous pas décidé

View file

@ -357,12 +357,14 @@ runbooks:
nature: ecriture
portee: poste
variables: [PARALLELE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
du plan : on ne les saisit nulle part.
- cible: deployer-tout
nature: ecriture
duree: "long"
fixes: {CONFIRMER: "true"}
pourquoi: >-
Toute la flotte, dans l'ordre des couches. L'ordre vient du graphe de
dependances, pas d'une liste tenue a la main.
@ -449,6 +451,7 @@ runbooks:
nature: ecriture
portee: poste
duree: "long"
fixes: {CONFIRMER: "true"}
pourquoi: >-
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
suffit pas, c'est ici qu'on l'apprend.
@ -635,18 +638,32 @@ runbooks:
- cible: depots-perimes
nature: destructif
fixes: {CONFIRMER: "true"}
# FACULTATIVE, SINON ELLE BARRE LA PREUVE QUI LA SUIT. La console
# debloque d'office une etape « mesure » ; toute autre attend que la
# precedente non facultative ait REUSSI dans la session. Un menage
# qu'on peut ne pas avoir a faire — aucun depot perime ce jour-la —
# rendrait alors l'emancipation injouable. Le ménage n'est pas un
# prealable a la preuve : il nettoie ce que la filiation a laisse.
facultative: true
pourquoi: >-
Un depot raye du plan reste sur le disque du runner, qui garde de quoi lire un
ecosysteme qu'il ne declare plus. Trois choses ne sont jamais retirees : ce qui
n'est pas un depot git, ce qui porte des modifications non validees, et ce qui
porte des commits qu'aucun distant ne porte.
- cible: emancipation-prouver
nature: mesure
# ECRITURE, ET NON MESURE, MALGRE LE MOT « PROUVER ». La cible COUPE
# l'amont quelques secondes pour mesurer : elle refuse d'ailleurs sans
# CONFIRMER=true, et le dit. Le fait qu'elle rapporte un constat ne la
# rend pas inerte. Declarée « mesure », elle se serait offerte a tout
# outil qui ne propose que ce qui n'agit pas.
nature: ecriture
variables: [SERVICE, HOTE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Prouver qu'un lien est coupe. La mesure instruit ; l'humain decide. Une
emancipation automatique serait une expulsion.
Prouver qu'un lien est coupe, en le coupant. La coupure est retiree quoi
qu'il arrive, mais la fonction eprouvee peut echouer pendant ce temps.
La mesure instruit ; l'humain decide. Une emancipation automatique serait
une expulsion.
# ─────────────────────────────────────────────────────────────────────────────
- id: remise

View file

@ -29,6 +29,7 @@ carte perimee est pire que pas de carte, parce qu'on la suit.
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
@ -74,6 +75,39 @@ def cibles_du_makefile(racine: Path = RACINE) -> dict[str, str]:
return {nom: aide for nom, aide in MOTIF_CIBLE.findall(texte)}
def cibles_qui_exigent_confirmation(racine: Path = RACINE) -> set[str]:
"""Les cibles dont la RECETTE teste `CONFIRMER`.
POURQUOI LIRE LA RECETTE ET NON LE REGISTRE. Comparer `nature` a `fixes`
ne compare que deux champs ecrits par la meme main : le meme mensonge
repasse en EFFACANT la ligne `fixes`. La recette, elle, ne ment pas — elle
refuse, et c'est ce refus que l'exploitant rencontre.
Une recette va de sa ligne de cible jusqu'a la prochaine ligne non
indentee ; une ligne vide ne l'interrompt pas.
"""
texte = (racine / "Makefile").read_text(encoding="utf-8")
exigent: set[str] = set()
etat: dict = {"cible": "", "recette": []}
def _clore() -> None:
if etat["cible"] and "CONFIRMER" in "\n".join(etat["recette"]):
exigent.add(etat["cible"])
for ligne in texte.splitlines():
debut = re.match(r"^([A-Za-z0-9_.-]+):", ligne)
if debut:
_clore()
etat["cible"], etat["recette"] = debut.group(1), []
elif etat["cible"] and (ligne.startswith("\t") or ligne.startswith(" ")):
etat["recette"].append(ligne)
elif ligne.strip():
_clore()
etat["cible"], etat["recette"] = "", []
_clore()
return exigent
def charger(chemin: Path = REGISTRE) -> dict:
"""Le registre brut, tel qu'il est ecrit. Aucune jointure, aucune validation."""
if not chemin.is_file():
@ -165,6 +199,7 @@ def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]:
aides = cibles_du_makefile(racine)
catalogue = data.get("variables", {}) or {}
hors = data.get("hors_assistant", {}) or {}
exigent = cibles_qui_exigent_confirmation(racine)
ecarts: list[str] = []
portees_vues: dict[str, set[str]] = {}
@ -198,6 +233,28 @@ def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]:
if etape.get("nature") not in NATURES:
ecarts.append(f"{rid} → {cible} : nature « {etape.get('nature')} » hors "
f"vocabulaire ({', '.join(sorted(NATURES))}).")
# LA RECETTE TRANCHE, DANS LES DEUX SENS. Ce que la cible EXIGE
# se lit dans son Makefile ; ce que le registre PROMET se lit dans
# `fixes`. Comparer le registre a lui-meme laissait passer le meme
# mensonge ecrit dans l'autre sens — il suffisait d'effacer
# `fixes`, et trois cibles le portaient ainsi : un assistant les
# lancait sans confirmation, et sortait en 2.
declare = "CONFIRMER" in (etape.get("fixes") or {})
if cible in exigent and not declare:
ecarts.append(f"{rid} → {cible} : la recette refuse sans CONFIRMER "
f"et le registre ne le declare pas. Un assistant la "
f"lancerait telle quelle, et sortirait en 2.")
if declare and cible not in exigent:
ecarts.append(f"{rid} → {cible} : le registre declare CONFIRMER que "
f"la recette n'exige pas. Une confirmation de plus "
f"s'apprend a cliquer sans lire.")
# UNE MESURE QUI AGIT N'EN EST PAS UNE : une cible qui refuse sans
# confirmation TOUCHE au systeme, quel que soit le fait qu'elle en
# rapporte ensuite.
if etape.get("nature") == "mesure" and (declare or cible in exigent):
ecarts.append(f"{rid} → {cible} : declaree « mesure » alors qu'elle "
f"exige CONFIRMER. Une mesure qui demande "
f"confirmation agit.")
if not etape.get("pourquoi"):
ecarts.append(f"{rid} → {cible} : aucun « pourquoi ». Une etape qui ne dit "
f"pas pourquoi elle est la se fait sauter.")
@ -226,7 +283,21 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("action", choices=("verifier", "lister", "couverture"),
nargs="?", default="verifier")
# POURQUOI UN --json. Le registre est la seule source qui dise la NATURE
# d'une etape et la confirmation qu'elle exige. Un outil tiers qui veut
# n'offrir que ce qui ne detruit pas doit donc le lire — et sans drapeau,
# il n'a le choix qu'entre analyser un arbre fait pour l'oeil humain, qui
# derive au premier changement de mise en page, et importer ce module,
# ce qui le lie a nos noms internes. Les deux se paient plus tard.
parser.add_argument("--json", action="store_true",
help="Rend le registre assemble en JSON (avec "
"« lister »), pour un outil tiers.")
args = parser.parse_args()
# RENDRE LA PROSE HUMAINE A QUI DEMANDE DU JSON est pire qu'un refus :
# l'appelant lit une reponse qu'il croit analysable, et ne s'en apercoit
# qu'au premier caractere inattendu.
if args.json and args.action != "lister":
parser.error("--json n'existe qu'avec « lister ».")
if args.action == "verifier":
ecarts = verifier()
@ -242,6 +313,11 @@ def main() -> int:
return 0
if args.action == "lister":
if args.json:
# Sortie NUE, sans en-tete ni resume : un appelant la lit d'un
# bloc. `ensure_ascii=False` garde les accents du registre.
print(json.dumps(assembler(), ensure_ascii=False))
return 0
for rb in assembler():
print(f"\n{rb['id']} [{rb['portee']}] {rb['titre']}")
print(f" {rb['but']}")

View file

@ -13,7 +13,9 @@ etape par son nom seul confondrait les deux.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
@ -105,6 +107,152 @@ class TestGardeDesRunbooks(unittest.TestCase):
self.assertTrue(any("n'est pas au catalogue" in e for e in ecarts), ecarts)
class TestUneMesureNagitPas(unittest.TestCase):
"""`nature` est le seul champ sur lequel un outil tiers peut decider de
n'offrir que ce qui n'agit pas. Il faut donc qu'il ne mente pas.
Le registre portait une etape declaree « mesure » et refusant de tourner
sans CONFIRMER : la cible COUPE l'amont quelques secondes pour mesurer.
Rien ne le disait, et un lecteur refaisait l'enquete a chaque passage.
"""
def setUp(self):
self.temporaires: list[Path] = []
def tearDown(self):
for p in self.temporaires:
p.unlink(missing_ok=True)
def _verifier(self, texte: str) -> list[str]:
p = _registre(texte)
self.temporaires.append(p)
return rb.verifier(RACINE, p)
def _muter(self, avant: str, apres: str) -> list[str]:
"""Les ecarts d'un registre mute. La MUTATION est verifiee : une
substitution qui ne s'applique plus rendrait le controle vert sans
avoir rien change."""
faux = SOURCE.replace(avant, apres, 1)
self.assertNotEqual(faux, SOURCE, "la substitution ne s'applique plus")
return self._verifier(faux)
def test_une_mesure_qui_exige_une_confirmation_est_refusee(self):
"""`depots-perimes` refuse sans CONFIRMER : la declarer « mesure »
fait mentir le seul champ sur lequel un tiers decide."""
ecarts = self._muter(""" - cible: depots-perimes
nature: destructif""", """ - cible: depots-perimes
nature: mesure""")
self.assertTrue(
any("mesure" in e and "CONFIRMER" in e for e in ecarts), ecarts)
def test_une_ecriture_qui_exige_une_confirmation_est_normale(self):
"""Controle positif : refuser toute confirmation fermerait les
etapes d'ecriture que le moteur garde deliberement ainsi."""
ecarts = self._muter(""" - cible: depots-perimes
nature: destructif""", """ - cible: depots-perimes
nature: ecriture""")
self.assertEqual(
[], [e for e in ecarts if "CONFIRMER" in e], ecarts)
def test_une_cible_qui_refuse_sans_confirmation_doit_la_declarer(self):
"""L'INVARIANT DANS L'AUTRE SENS. Comparer `nature` a `fixes` ne
compare que deux champs ecrits par la meme main : effacer `fixes`
faisait repasser le meme mensonge. La recette, elle, refuse — et
c'est ce refus que l'exploitant rencontre, en sortie 2."""
ecarts = self._muter(
""" - cible: depots-perimes
nature: destructif
fixes: {CONFIRMER: "true"}""",
""" - cible: depots-perimes
nature: destructif""")
self.assertTrue(
any("sortirait en 2" in e for e in ecarts), ecarts)
def test_une_confirmation_declaree_que_la_recette_nexige_pas_est_refusee(
self,
):
"""Une confirmation de plus s'apprend a cliquer sans lire."""
ecarts = self._muter(""" - cible: lint
nature: mesure""", """ - cible: lint
nature: ecriture
fixes: {CONFIRMER: "true"}""")
self.assertTrue(
any("n'exige pas" in e for e in ecarts), ecarts)
def test_la_recette_se_lit_vraiment(self):
"""Un lecteur de recettes qui rendrait un ensemble vide passerait
les refus ci-dessus sans avoir regarde le Makefile."""
exigent = rb.cibles_qui_exigent_confirmation(RACINE)
self.assertGreater(len(exigent), 5)
self.assertIn("depots-perimes", exigent)
self.assertNotIn("lint", exigent)
def test_aucune_mesure_du_registre_reel_nexige_de_confirmation(self):
for rbk in rb.assembler():
for etape in rbk["etapes"]:
if etape["nature"] != "mesure":
continue
with self.subTest(runbook=rbk["id"], cible=etape["cible"]):
self.assertNotIn("CONFIRMER", etape.get("fixes") or {})
class TestLeRegistreEnJson(unittest.TestCase):
"""Un outil tiers lit le registre sans analyser un arbre fait pour l'oeil,
et sans importer ce module — deux liens qui se paient plus tard."""
def _lister_json(self) -> list[dict]:
sortie = subprocess.run(
[sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"),
"lister", "--json"],
capture_output=True, text=True, cwd=RACINE, check=False)
self.assertEqual(0, sortie.returncode, sortie.stderr)
return json.loads(sortie.stdout)
def test_le_json_dit_la_meme_chose_que_le_registre_assemble(self):
self.assertEqual(rb.assembler(), self._lister_json())
def test_le_json_porte_la_nature_et_les_fixes_de_chaque_etape(self):
"""Ce sont les deux champs sur lesquels un tiers decide d'offrir une
etape ou non : absents, le drapeau ne sert a rien."""
etapes = [e for rbk in self._lister_json() for e in rbk["etapes"]]
self.assertTrue(etapes)
for etape in etapes:
with self.subTest(cible=etape.get("cible")):
self.assertIn(etape.get("nature"), rb.NATURES)
self.assertIsInstance(etape.get("fixes"), dict)
def _lister_humain(self) -> str:
sortie = subprocess.run(
[sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"),
"lister"],
capture_output=True, text=True, cwd=RACINE, check=False)
self.assertEqual(0, sortie.returncode, sortie.stderr)
return sortie.stdout
def test_laffichage_humain_porte_toujours_ce_qu_il_portait(self):
"""Controle positif. Exiger seulement que la sortie ne soit PAS du
JSON laissait passer trois regressions, dont « ne rien afficher ».
On mesure donc ce qu'elle doit PORTER : un identifiant de runbook,
un libelle de cible, et les trois marques de nature."""
vu = self._lister_humain()
premier = rb.assembler()[0]
self.assertIn(premier["id"], vu)
self.assertIn(premier["etapes"][0]["cible"], vu)
for marque in ("*", "!"):
self.assertIn(marque, vu)
with self.assertRaises(json.JSONDecodeError):
json.loads(vu)
def test_le_drapeau_est_refuse_hors_de_son_action(self):
"""Rendre la prose humaine a qui demande du JSON est pire qu'un
refus : l'appelant lit une reponse qu'il croit analysable."""
sortie = subprocess.run(
[sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"),
"verifier", "--json"],
capture_output=True, text=True, cwd=RACINE, check=False)
self.assertNotEqual(0, sortie.returncode)
class TestPorteeParEtape(unittest.TestCase):
"""La portee se pese a l'etape — mesure faite sur la console de TechnoLibre.