# CHANGELOG — Set-OPS ## 2026-09-27 (1) — Patient 0 effacé, l'index 29 libéré Ses machines n'existaient plus depuis le 2026-09-06 (D-83), mais son plan restait sur disque en `federe: true` : la fédération lui réservait l'index 29, et quatre machines du site ouvraient SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide. **Ce qui est fait :** - `SITE-Chezlepro/underlay.yml` : `OPS-Patient0: 29` retiré de `tenants`. - `SITE-Chezlepro/plan/serveurs.yml` : le runner du site ne clone plus `ops-patient0`. - `make flux` : `10.29.0.0/16` sort des règles de `site-backup-01`, `site-cache-01`, `site-dns-01`, `site-dnspub-01` et `site-forge-01` — et rien d'autre ne change. - Le dépôt local `OPS-Patient0/` est supprimé ; sa copie reste sur `eregion`. - Les commentaires et documents vivants qui le nommaient gardent leur leçon sans le nommer (moteur, modèles, tenants, sites). Le CHANGELOG, les rapports de preuve datés et la chronique restent tels quels : ce sont des archives. - `filiation-emancipation.md` §« Qui est le parent ? » ramené à la décision et à la dette qui reste — `eregion`, hors flotte, alimente la forge qui fait autorité. **Ce qui n'est pas encore sur le réseau.** Les `.nft` régénérés doivent être posés par le runner du site. Le SDN (zone `t29`, VLAN 1291-1296), la frontière et le pare-feu Proxmox n'ont pas pu être relus depuis le poste : le cluster ne répondait pas (22 et 8006). **Mesuré en passant.** `make sdn-plan`, cluster injoignable, annonce « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Il lit un cluster muet comme un cluster vide, là où `frontiere-plan` refuse de conclure. `make verifier` : 82 OK, 1 échec — P02, `test_ecriture_plan.py` sur `domaines.yml` (`'list' object has no attribute 'get'`), présent avant ce changement. ## 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é d'un plan d'adressage dérivé pour chaque flotte, qu'elle soit tenant ou site ? » Oui. La décision est écrite dans l'underlay du site, datée du 12 septembre : > *« sites et locataires se partagent la classe A, chacun avec SON index. Le site prend > 37, le locataire garde 17. `make instances` les compte ensemble depuis ce jour. »* ### Ce que l'entrée (11) affirmait, et qui était faux Elle disait qu'un site *« déclare ses adresses, il n'a aucun seed dont les faire descendre »*. C'était la recopie d'un commentaire en tête de `SITE-Chezlepro/plan/serveurs.yml` — **périmé depuis le jour où le site a reçu son index**, et lu au lieu d'être mesuré. Un commentaire périmé se propage : celui-ci s'est retrouvé le même jour dans la docstring de `ConsoleSite` et dans une entrée de CHANGELOG. C'est exactement ce que `CARTE-DU-SITE.md` décrit chez un locataire — **inerte et crédible** : le moteur ne le lit pas, donc rien ne le corrige ; un humain le lit, et le croit. ### La mesure Les sept zones du site suivaient `10...0/24` **à la lettre** — et étaient pourtant **écrites** : `sous_reseau` et `passerelle` stockés pour chacune. C'est précisément ce que P20 interdit à un locataire, sous le titre *« Adressage 100 % dérivé du seed (aucun stocké) »*. Et la portée de P20 était *« l'instance liée + les modèles »* : **elle ne regardait jamais le site**. Les valeurs concordaient parce qu'un humain les avait tapées juste, ce jour-là. Rien ne le garantissait : une zone écrite `10.36.x` serait passée sans un mot. ### Deux natures dans une seule liste `reseaux:` mélangeait ce qui peut dériver et ce qui ne le peut pas. Chaque entrée déclare maintenant sa nature : | `nature: fabric` | grappe, iSCSI, Ceph, transit, VXLAN, management | adressage dicté par les participants d'un lien physique (D-02, D-04) — il reste **écrit** | | `nature: site` | les sept zones du site | elles **descendent de son index**, et ne stockent plus rien | Le loader dérive `sous_reseau` et `passerelle` à la lecture, pour que les consommateurs ne voient aucune différence. **Quatorze valeurs stockées ont disparu du fichier, et ce que les consommateurs lisent est identique — écart mesuré : zéro.** `make underlay`, `make devis-sdn` et `make devis-reseau` passent. P20 juge désormais les deux natures, et refuse une entrée qui ne déclare pas la sienne : on ne peut pas juger ce qu'on ne sait pas lire. Contrôle négatif joué : une zone remise à `10.36.31.0/24` est refusée, en nommant l'index dont elle aurait dû descendre. ### Ce qui reste écrit, et qui est une dette nommée Les **machines** du site gardent leurs `ip`, `vmid` et `noeud` dans son plan. Ce n'est plus une doctrine — « le site est le terrain, il n'a rien dont faire descendre » — puisque le seed existe maintenant pour elles aussi. C'est une dette, et la nommer est le minimum : *un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.* **P02 et P60 restent**, pour les raisons déjà consignées. ## 2026-09-20 (11) — Deux fonctions qui doivent rendre la même forme finissent par ne plus la rendre **83 preuves, `make test` à 0 échec, 8 tests de rendu.** La console avait deux assembleurs de charge — `inventaire_api` pour un locataire, `inventaire_api_du_site` pour un site. Ils ont dérivé deux fois le même jour, et ce n'était pas une malchance : c'est ce qui arrive toujours à deux copies d'une même obligation. ### Ce que la duplication a coûté, mesuré **En forme.** `serveurs` valait `[]` d'un côté et `{}` de l'autre. Les deux « vides », et la page ne les lit pas pareil : `charger()` levait, et la console d'un site mourait avant sa première vue. **En contenu.** Cinq registres étaient servis vides — « pour ne pas fabriquer un faux plan de tenant ». Le site n'a pas de faux plan : il a **le sien**, au même format, que les lecteurs du moteur lisent sans broncher. Mesure : **9 serveurs, 21 applications, 2 bases, 1 domaine**, tous invisibles à l'écran. ### Un tronc, deux branches, et le rôle distinct de la portée `Console` assemble — **un seul endroit** où la charge se compose, et c'est lui qui fixe les clés et leurs formes. Les branches ne décident que de ce qui leur appartient : d'où vient le plan, d'où vient l'inventaire, quels pouvoirs elles portent. | Branche | Ce qu'elle est | Son plan | Son inventaire | |---|---|---|---| | `ConsoleLocataire` | configure ses services | dérivé d'un seed | artefact généré | | `ConsoleSite` | matérialise le terrain | **déclaré** (`ip`, `vmid`, `noeud`) | un script | | `ConsolePoste` | l'atelier — hérite du locataire | idem locataire | idem locataire | | `Console` | ne pilote rien, et le dit | aucun | vide | **Le rôle et la portée ne se confondent pas.** Le rôle est une propriété de la classe : ce pour quoi cette console existe. La portée se **calcule** depuis les pouvoirs : ce qu'elle peut vraiment, ici et maintenant. Un poste privé de la voûte du site reste l'atelier du mainteneur et n'engendre pourtant rien — figer la portée sur la classe lui aurait rendu un pouvoir que la mesure lui refuse. Ce découpage a immédiatement montré un trou : `ConsolePoste` héritait du refus d'un locataire — *« elle ne sait pas sur quelle fabric poser »* — alors qu'il **monte** la carte. Ce qui lui manque, c'est la voûte. Un message qui accuse la mauvaise absence fait chercher au mauvais endroit. ### Le jugement des assistants remonte dans le tronc Il était écrit deux fois : dans la route qui **liste** les assistants, et dans celle qui **exécute** une étape. Deux copies d'une même règle finissent par ne plus dire la même chose — et **celle qui se trompe est toujours celle qui exécute, parce que c'est la moins relue**. `Console.peut_conduire()` répond aux deux. Le gestionnaire ne demande plus « est-ce un site ? » pour choisir un assembleur : il demande sa charge à sa console, et ignore laquelle lui répond. ### Ce qui a été vérifié `make test` à 0 échec, les 8 tests de rendu sous `node` — dont deux neufs : la console de site sert bien son plan, et les deux charges portent les mêmes types. La console a été lancée pour de vrai : contexte `poste`, 13 serveurs, 24 applications, 17 runbooks, 127 étapes conduisibles, 0 écart. **Et `make verifier` a trouvé une faute que j'avais laissée passer** : le playbook `depots_perimes.yml` livré une heure plus tôt violait `risky-shell-pipe`. J'avais passé `--syntax-check` dessus et `ansible-lint` seulement sur le rôle voisin. Corrigé — les tubes retirés, `pipefail` armé sans `-e` pour qu'un `git` qui échoue laisse le script répondre au lieu de faire tomber la tâche. **Deux échecs restent.** P02, antérieur, consigné à l'entrée `(6)`. Et **P60** : le wiki publié est en retard d'un commit depuis que l'entrée `(6)` a documenté les Assistants — `make wiki-publier WIKI_REMOTE=…` le republie, et l'adresse publique n'est celle d'aucun plan, donc elle ne se devine pas. ## 2026-09-20 (10) — Un vide doit avoir la forme de ce qu'il remplace **83 preuves, `make test` à 0 échec, 7 tests de rendu.** La console de `console.genese.internal` — celle du runner de SITE-Chezlepro — ne montrait rien. Elle a douze machines. ### `charger()` levait, et la page mourait avant de dessiner `inventaire_api` sert `serveurs` en **liste** ; `inventaire_api_du_site` le servait en **table**. Les deux sont « vides », et la page ne les lit pas pareil : `(data.serveurs || []).map(...)` trouve `{}` — *truthy*, sans `.map` — et `charger()` lève. Toute la console d'un site s'arrêtait là, avant la première vue, et le message accusait une méthode manquante plutôt qu'une forme qui ment. Un vide doit avoir la **forme** de ce qu'il remplace. Sinon ce n'est pas un vide, c'est un autre objet qui se fait passer pour lui — et le malentendu ne se voit qu'au moment où quelqu'un appelle une méthode dessus. Un test compare désormais les deux charges **type par type** pour les clés qu'elles partagent, avec deux écarts admis et motivés. Il ne demande pas les mêmes valeurs — un site n'a pas de plan de locataire — seulement les mêmes formes. ### Et la vue regardait au mauvais endroit Le registre vide était **voulu** : « on ne fabrique pas un faux plan de tenant pour remplir l'écran ». C'est juste — un site déclare ses machines dans son propre plan, avec `ip`, `vmid` et `noeud` **écrits**, là où un locataire les dérive de son seed ; il est le terrain, il n'a rien dont les faire descendre. Mais la vue d'accueil tirait de ce registre vide le message d'un locataire sans serveurs — « + Serveur », `make serveurs-bootstrap` — et les douze machines, servies dans `hotes`, n'étaient dessinées nulle part. Une console de site montre maintenant **ses** machines : mêmes tuiles que les serveurs d'un locataire, en lecture seule, avec leur adresse, leur VMID, leurs rôles et leur point de vie ; le panneau droit dit ce que cette console peut — matérialiser le terrain, n'entrer chez aucun locataire — et renvoie aux Assistants. **C'est le défaut du 2026-09-16 par une autre porte.** La première fois, l'inventaire était vide et la page dessinait ce vide comme un plan vide. Cette fois l'inventaire est **plein** et c'est la vue qui regarde ailleurs. Le premier correctif a donné à la console de quoi répondre ; il ne lui a pas appris où regarder. ### Ce qui a trouvé le défaut Pas une lecture : **le banc**. `test_rendu_gui.py` dessine les vues sous `node` dans un DOM simulé. En lui donnant pour la première fois la charge d'une console de SITE, il a levé `(data.serveurs || []).map is not a function` — c'est-à-dire la cause réelle, sous le symptôme que je m'apprêtais à corriger. Sans lui, j'aurais livré une vue juste par dessus un `charger()` qui lève, et la console serait restée vide. ### Ce qui a été vérifié `node --check` sur le bloc `