# CHANGELOG — Set-OPS ## 2026-09-28 (14) — Technolibre entièrement filtré par Proxmox, VM par VM **Les quatre dernières, une à la fois** : `infra-dns-01`, `obs-01`, `ops-01`, `mon-01`. Pour chacune : matrice complète tirée du devis, flux entrants établis observés et confrontés aux règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes : avant = après, 0 critique. Technolibre : 13/13 VM filtrées. **Ce que la procédure a arrêté, à raison, et pourquoi c'était sans défaut :** - `obs-01` se parle à lui-même par sa propre adresse (Prometheus, Alloy→Loki) : ce trafic passe par `lo`, jamais par le pont de Proxmox — l'analyse l'écarte désormais ; - `infra-edge-01 → ops-01:8090` et `→ mon-01:8080` échouaient DÉJÀ avant : en mode `oidc`, nginx n'écoute qu'en local (le défaut le documente — une passerelle SSO qu'on ne peut pas contourner) ; c'est `oauth2-proxy` (4180) qui est publié, et la matrice le teste. La règle du port nginx reste pour le mode `locale`, sans objet ici. **Vérifié au-delà de la matrice** : résolution DNS réelle en UDP à travers `infra-dns-01` (noms internes et externes, depuis deux nœuds) ; pour `mon-01`, les 13 rapports `sante` reçus APRÈS l'activation — le flux passif vers 5665 traverse le filtre. ## 2026-09-28 (13) — Technolibre, deuxième lot : l'edge, l'identité, la base, la PKI **Activées** : `infra-edge-01`, `idm-01`, `data-sql-01`, `infra-pki-01`. **Deux vérifications, parce qu'une seule ne suffit pas.** La matrice tirée du devis ne teste que ce qui est DÉCLARÉ ; un flux réellement utilisé mais non déclaré la passerait, puis serait coupé. Donc, en plus : relever sur chaque VM les connexions entrantes ÉTABLIES (trois relevés), et vérifier que chaque couple source→port est couvert par une règle. 11 flux observés, 0 hors des règles (SSH d'administration et forme IPv4-mappée d'`obs-01` comprises). Matrice étendue à TOUS les membres de chaque source : 100/100 avant, 100/100 après. Sondes de santé relancées sur les 13 nœuds : 133 services revérifiés, aucun critique. ## 2026-09-28 (12) — Technolibre, premier lot : quatre VM de plus filtrées, preuve comprise **Activées** : `web-dorsal-01`, `collab-01`, `edge-mta-01`, `infra-mail-01`. Méthode : une matrice tirée du DEVIS lui-même — pour chaque règle des groupes de ces VM, un hôte réel de la source autorisée → VM:port —, jouée AVANT (19/19) puis APRÈS (19/19, aucun changement). Icinga : aucun critique. **Le filtrage mord, et c'est Proxmox qui mord.** Un contrôle négatif doit distinguer les deux couches : nftables, dans la VM, accepte TOUT l'ICMP ; Proxmox ne l'accepte que de `srv-icinga`. Depuis `data-sql-01` : pas de réponse des VM filtrées (`collab-01`, `web-frontal-01`), réponse des non filtrées (`idm-01`, `infra-pki-01`). **Un trou à fermer AVANT d'exposer un locataire.** Le devis saute les flux dont la seule source est `externe` (« la frontière s'en charge »). Mais un flux redirigé par la frontière arrive sur la VM avec sa source Internet : sous `REJECT`, sans règle, il serait refusé. Sans effet aujourd'hui — la frontière ne redirige vers aucune VM de locataire (relu : seul le DNS public du site) —, bloquant le jour d'une exposition. ## 2026-09-28 (11) — Première VM filtrée par Proxmox, et le ping de supervision qu'aucune règle ne portait **Activée : `web-frontal-01` de Technolibre** (`--vm 123603101`), pare-feu `enable=1`, `policy_in=REJECT`, groupes `cli-metrique`, `srv-debian`, `srv-web-frontal`. Vérifié flux par flux : SSH du poste, rapports de santé et de sauvegarde acceptés, `node_exporter` joint par `obs-01`, SSH depuis la flotte (autorisé par `srv-debian`, déclaré), port 80 ouvert à l'edge seul (rien n'y écoute encore : aucun site). Icinga : tout vert — sauf un critique NOUVEAU. **`ping4` : 100 % de pertes.** Le ping de supervision EST déclaré (`serveur_debian`, `echo-request` depuis `serveur_icinga`), mais `_ports()` ne garde que les nombres : le type ICMP était rangé parmi les « ports dérivés » et le flux sauté. Aucune règle, donc `REJECT`. `_cibles()` rend désormais un flux ICMP en règle `proto: icmp` + `icmp_type`, que l'applicateur transmet (`icmp-type`) et compare. Appliqué : `srv-debian` des deux locataires accepte `echo-request` depuis `srv-icinga` ; `ping4` revenu OK (0,20 ms). **Vérifié avant d'aller plus loin** : le « sans source » `serveur_icinga:5665` que recense le devis est le flux de `serveur_backup`, qu'aucun locataire ne porte — les rapports passifs, eux, ont leurs règles (`cli-backup`, `srv-debian`). Et le recensement ne déclare plus sauté un flux ICMP qu'il rend. ## 2026-09-28 (10) — Le pare-feu est-ouest de Proxmox ne filtrait aucune VM des locataires **Mesuré.** Pare-feu du datacenter actif, `firewall=1` sur les cartes des 26 VM de Chezlepro et Technolibre — mais AUCUNE n'avait son propre pare-feu activé ni un seul groupe affecté. Proxmox ne filtrait rien entre elles ; seul nftables, dans chaque VM, tenait l'est-ouest. Le plan de `proxmox-fw-appliquer` le rattrapait d'un bloc : 64 changements, dont l'activation en `REJECT` des 26 VM à la fois — autant de pannes possibles si une règle manquait. **Par étapes.** `appliquer_proxmox_fw.py` prend `--objets-seulement` (IPSets et groupes, aucune VM) et `--vm ` (répétable) ; le plan affiché est exactement ce qui sera appliqué. Posés aujourd'hui, les OBJETS seuls, inertes tant qu'aucune VM ne les porte : IPSets `admin` passés aux sources d'administration actuelles (`10.37.0.0/24`, VPN `10.37.29.0/24`), membres retirés (ancien `forge-01`, `x.18.21`), objets des rôles disparus (`backup`, `forgejo`, `artefacts`) retirés, `srv-debian`, `srv-ops`, `srv-powerdns`, `srv-ops-tenant` créés, douze groupes remis au devis. Relu : objets conformes, restent les 26 affectations. ## 2026-09-28 (9) — P02 : l'écriture d'une table s'arrêtait au premier commentaire **`make verifier` : CONFORME, 83 OK, 0 échec** — P02 échouait depuis le 2026-09-16. **La cause.** `_fusion_table` (écriture ligne à ligne des registres du plan, par le GUI) délimitait une entrée par les lignes plus indentées qui la suivent, et **s'arrêtait à la première ligne de commentaire**. Le 2026-09-16, `chezlepro.ca` a reçu des commentaires À L'INTÉRIEUR de son entrée (signature DNSSEC, relevé de la zone) : l'entrée était coupée en deux, et les lignes suivantes testées comme entrées de la table. ` - {nom: "@", type: A, …}` y passait, clef `- {nom`, YAML = une liste → `'list' object has no attribute 'get'`. Le GUI ne pouvait plus écrire `domaines.yml`. **Le correctif.** L'étendue d'une entrée traverse commentaires et lignes vides tant que la prochaine ligne significative est encore plus indentée ; un commentaire suivi de l'entrée SUIVANTE reste avec elle. Et seules les lignes à l'indentation des enfants directs peuvent être des entrées. Réécrire le vrai `domaines.yml` sans changement le rend octet pour octet. **Le test aussi avait une prémisse périmée.** Retirer une entité devait garder TOUS les commentaires du fichier — juste tant qu'aucune entrée n'en portait à l'intérieur. Un commentaire intérieur part désormais avec son entrée (laissé, il expliquerait la voisine) ; ceux qui l'entourent restent, et c'est ce que le test exige maintenant. ## 2026-09-28 (8) — L'amont du cache des locataires suit le site, au lieu d'une adresse morte `serveur_artefacts_amont` valait `http://10.0.33.21:3142` chez Chezlepro ET Technolibre : l'adresse du cache du site avant qu'il prenne son index (`10.37.3x`). Inerte aujourd'hui — aucun hôte de ces écosystèmes ne porte `serveur_artefacts`, et `apt` passe par `artefacts_amorcage` (mesuré sur les nœuds : `10.37.33.21:3142`) —, mais un piège le jour où un locataire reprendrait son propre cache : son amont serait né mort. La preuve d'amorçage ne regardait que les intrants, pas ce fichier. La valeur est désormais DÉRIVÉE : `"http://{{ artefacts_amorcage }}"`, la seule adresse que cette preuve garde alignée. ## 2026-09-28 (7) — Rotation des clés WireGuard de l'exploitant, sans qu'aucune privée ne s'affiche **Pourquoi.** Les deux clés privées de `daniel-portable` (tunnel du site, tunnel de Chezlepro) ont fui dans l'historique d'une session d'assistance. On les change. **Ce que `vpn_admin.py` ne savait pas faire.** `pair-nouveau` AFFICHE la privée pour qu'on la colle ; lancé par un assistant ou dans un terminal journalisé, cet affichage la dépose dans un historique — exactement la fuite qu'on répare. Trois ajouts : - `cle-appareil --vers ` : la privée va droit dans un fichier en 0600 (refus d'écraser), seule la publique s'affiche. Sert à la création comme à la rotation ; - `config --cle --vers ` : la configuration COMPLÈTE, écrite en 0600 ; - `plan|appliquer --portee site --portee OPS-X` : sans filtre, `appliquer` réconcilie TOUS les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair retiré que personne n'a encore décidé de retirer). Et `service : ?` après un rechargement réussi : OPNsense répond `result`, pas `status`. **Fait.** Deux paires tirées dans `~/wireguard-rotation/` (hors dépôt), `cle_publique` remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives, les deux anciennes absentes. Technolibre intact. ## 2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent **Ce qui manquait.** La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et chaque secret était à régénérer. La runbook `cles` affirmait pourtant « les voûtes sortent chiffrées ». **`scripts/exporter_voutes.py`** (`make voutes-recenser`, `make voutes-exporter VERS=…`). Une archive à part, `setops-voutes--.tar` : `restaurer_cles.py` range chaque clé d'après son seul nom, et les voûtes s'appellent presque toutes `vault.yml` — elles s'y seraient écrasées. Chaque voûte garde donc son chemin relatif aux dépôts ; restauration : `tar -xf … -C `. Pas de seconde couche : elles sont déjà chiffrées par une clé qui n'est pas dans cette archive, et le script REFUSE tout fichier dont l'en-tête n'est pas `$ANSIBLE_VAULT`. Il refuse aussi une destination dans l'infrastructure et l'écrasement d'une archive existante, puis relit empreinte par empreinte. **Fait et éprouvé** : six voûtes (Chezlepro, Chezlepro-lab ×2, Technolibre, les deux sites) sur la clé USB, 80 Ko ; extraites dans un répertoire temporaire, chacune rouverte avec les clés (33, 25, 2, 37, 20 et 14 entrées), puis détruites. Le mode d'emploi de la clé, la runbook `cles` et `sortir-les-cles-du-poste.md` le disent désormais. À refaire après toute écriture dans une voûte. ## 2026-09-28 (5) — L'exportateur PostgreSQL de Chezlepro, et où vivent vraiment les voûtes **Le dernier critique de Chezlepro.** `obs-01!collecte` : Prometheus scrutait `data-sql-01:9187`, où rien n'écoutait. `serveur_postgresql` ne pose l'exportateur que si `vault_pg_exportateur` existe ; Technolibre l'avait, pas Chezlepro. Ajouté à sa voûte selon la procédure (copie, lecture par un tube et comptage : 32 clés, chiffrement vers un fichier neuf relu : 33 clés, les 32 d'origine identiques, puis remplacement). Rôle redéployé : exportateur actif, `pg_up 1`. Chezlepro n'a plus aucun critique. **La promesse alignée sur Prometheus (même jour).** `vault.yml.example` promettait « VIDE = … Prometheus ne dérive aucune cible » ; le gabarit dérive pourtant la cible de tout hôte de `serveur_postgresql`, secret ou pas. Le comportement est gardé — une base sans métriques doit se voir — et la phrase dit désormais ce qui arrive : sans le secret, la collecte d'`obs-01` rougit, et renseigner le secret l'éteint. Corrigé dans les quatre exemplaires : `exemples/vault.exemple.yml`, `docs/metriques-conception.md`, et les `vault.yml.example` de Chezlepro et de Technolibre. **Où vivent les voûtes.** `sortir-les-cles-du-poste.md` et `exporter_cles.py` disaient les voûtes chiffrées répliquées sur les forges. Elles sont gitignorées : chacune vit sur le poste et sur le runner de son écosystème (copie chiffrée par `serveur_ops_tenant`). Le runner de Chezlepro portait donc la voûte d'avant ce changement ; redéposée, empreintes identiques. Les deux phrases sont corrigées. ## 2026-09-28 (4) — Une sonde posée sous condition n'est attendue que là où elle est posée **Ce que la fraîcheur a fait voir.** Chez les deux locataires, `obs-01!journaux-frontiere` est passé au rouge et y serait resté pour toujours. `serveur_loki` ne pose cette sonde que si une étiquette de frontière est déclarée — au SITE, jamais chez un locataire, dont la frontière appartient à l'hébergeur — et la RETIRE sinon. Icinga, lui, l'attendait sur tout hôte du groupe. Trois rôles posent ainsi leur sonde sous `when:` (`journaux-frontiere`, `audit`, `console-ops`) ; les deux autres n'étaient pas rouges par chance, leur condition étant vraie partout. **Le correctif.** La déclaration porte la même condition que le dépôt : `seulement_si: ` dans `meta/supervision.yml`, lue par `setops-sondes.conf.j2` dans l'inventaire de l'hôte, sinon dans les défauts du rôle qui la définit (`defini_par`) — la valeur même que ce rôle voit. `test_sondes_conditionnelles.py` (dans `make test`) exige qu'un `when:` qui pose une sonde ait son `seulement_si`, sur la même variable, avec un défaut littéral ; vérifié en retirant volontairement une condition. Simulé puis appliqué : chez chaque locataire, un seul changement — `journaux-frontiere` retiré d'`obs-01`. Technolibre n'a plus aucun critique ; Chezlepro n'en garde qu'un, son exportateur PostgreSQL, faute du secret `vault_pg_exportateur`. **Corrigé le même jour : les journaux de la frontière SONT couverts.** Cette entrée affirmait le contraire, tirée d'un `--diff` où `journaux-frontiere` n'apparaissait pas — un diff ne montre que ce qui CHANGE, pas ce qui existe. Mesuré ensuite : la frontière envoie en TCP vers `site-mon-01:1514` (Alloy), qui porte bien `serveur_loki` ; la sonde `site-mon-01!journaux-frontiere` est verte, 748 lignes reçues en 10 min. ## 2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge **Le trou.** Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient déclarés `enable_active_checks = false`, avec une commande qui exécutait `/bin/true`. Le `ttl` de chaque envoi couvrait le silence qui SUIT un rapport ; un service qui n'en a jamais reçu restait « en attente », gris, pas rouge. C'est ce qui a caché dix nœuds de Chezlepro sans sauvegarde pendant seize jours. **Le correctif.** Un gabarit, `setops-rapport-attendu` (dans `setops-sauvegardes.conf.j2`), que les trois familles importent : service ACTIF, commande `dummy` en CRITIQUE, `check_interval` = le seuil — patron documenté d'Icinga 2 pour la fraîcheur. Chaque rapport repousse l'échéance ; seul le silence la laisse arriver, y compris le silence initial. Seuils = les `ttl` que les nœuds envoient (6 h, 45 min, 90 min), dans `serveur_icinga_fraicheur_*`. Une liste qui en suit une autre : `test_fraicheur_icinga.py` (dans `make test`) les compare et refuse tout service passif sans échéance — vérifié en cassant volontairement une valeur. **Prouvé en vrai** sur `site-mon-01` : rapport envoyé avec un `ttl` de 60 s → CRITIQUE à t+60 s (« AUCUN RAPPORT… ») → vrai rapport de santé → OK. Déployé sur les trois Icinga (site, Chezlepro, Technolibre) : 120 + 138 + 138 services, tous avec échéance. **Ce que ça a aussitôt montré.** Chez les deux tenants, dix services n'avaient JAMAIS reçu de rapport, et rougissent maintenant : `obs-01!journaux-frontiere`, et neuf sondes de rôle (console, passerelle, édition, cache, filtrage, sites/apps servis) — leur `client_sante` n'a pas été redéployé depuis que ces sondes existent. Leur Icinga surveillait aussi encore `forge-01`, retiré du plan : la configuration est réalignée, mais `obs-01!collecte` reste rouge tant que Prometheus scrute l'ancienne adresse (`10.x.21.11:9100`). ## 2026-09-28 (2) — Deux locataires sur deux n'avaient pas de sauvegarde hors de leur flotte **Question de l'exploitant : les sauvegardes des tenants passent-elles ?** Non. Mesuré côté dépôt (`site-backup-01`, ce que l'hébergeur voit sans la clé) puis côté nœuds. **Technolibre — refusé chaque nuit, `restic-tech` vide.** Ses huit nœuds à état se présentaient comme `restic`, le compte du SITE : `Permission denied (publickey)`, plusieurs fois par jour. Chezlepro déclare deux lignes (`client_backup_cible` ET `client_backup_utilisateur_distant`) ; Technolibre n'avait recopié que la première, et le rôle retombait sur son défaut. Ajouté `restic-tech` (OPS-Technolibre `ef8c905`), déployé, sauvegarde lancée : huit premiers instantanés, 79 Mo ; le dump PostgreSQL restauré (4,4 Mo, 435 tables) se lit. **Chezlepro — dix nœuds sur onze sans `client_backup` du tout.** Seul `infra-pki-01` déposait (9 instantanés depuis le 2026-09-13). Les autres n'avaient ni script, ni minuteur, ni secret : ils ne frappaient même pas à la porte. Les dates de naissance le situent au redéploiement du 2026-09-12 au soir — `client_backup` y a abouti sur `infra-pki-01` (21:22) et `infra-dns-01` (21:28), les deux nœuds amorcés en premier, jamais sur les autres, alors que `client_sante`, plus haut dans `site.yml`, les a tous atteints (21:59). Aucun journal n'a été gardé : échec ou interruption, on ne sait pas. Le rôle, relancé sans aucune modification, a réussi partout. Sept premiers instantanés ; dump PostgreSQL (6 bases, 435 tables) et annuaire (12 entrées) restaurés et lus. `/var/vmail`, `/srv/web` et `/srv/webapp` sont vides sur le disque aussi : rien de manqué. **Pourquoi personne ne l'a vu.** Un nœud sans `client_backup` n'a pas non plus sa vérification : il ne rapporte RIEN. Un service passif qui n'a jamais reçu de résultat reste « en attente » dans Icinga — il ne passe jamais au rouge. Le `ttl` n'alerte que sur un silence qui SUIT un rapport. C'est le trou à fermer : une fraîcheur qui vaut aussi pour le premier rapport. ## 2026-09-28 (1) — La sauvegarde de la forge du génome passait ; sa vérification parlait dans le vide **Question de l'exploitant : la sauvegarde de `site-forge-01` passe-t-elle vraiment ?** Oui, et c'est maintenant mesuré jusqu'à la restauration : `setops-sauvegarde` réussit chaque nuit (dernier instantané 2026-09-28 02:43, 9 conservés, ~45 Mio), et `set-ops-public.git` restauré depuis `latest` passe `git fsck`, avec pour tête `859ac07` — exactement ce que la forge portait à 02:43, avant les poussées de la journée. **Ce que la mesure a trouvé en chemin.** La vérification que chaque nœud fait de SON dépôt (`setops-verification-depot`, celle qui détient la clé) échouait toutes les quatre heures depuis le 2026-09-20 sur `site-forge-01`, `site-mon-01` et `site-pki-01` : 48 fois « ECHEC du rapport Icinga : 404 No objects found ». Le nœud rapportait à `!sauvegarde` ; `setops-sauvegardes.conf.j2` ne créait ce service que dans un écosystème SANS dépôt. Le site en a un (`site-backup-01`) : seuls existaient les services du témoin du dépôt, `site-backup-01!sauvegarde: ` — au vert, et donc crus complets. Le gabarit choisissait l'un OU l'autre témoin ; il crée désormais les deux quand un dépôt existe. Déployé sur `site-mon-01`, vérification relancée sur les trois nœuds : six services au vert, et les deux témoins disent la même chose (1723 fichiers, 40 Mo pour la forge). Chez les tenants, sans dépôt, rien ne change. ## 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é, puis, le 2026-09-28, ses deux copies distantes : `genome/ops-patient0` sur la forge du site (API, 404 relu) et `Chezlepro/OPS-Patient0` sur `eregion` (interface web — le jeton du poste n'a pas `write:repository` ; 404 relu). La clé de sa voûte est détruite (`shred`). - 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 au chemin réel du génome. **Corrigé le 2026-09-28 : il n'y a pas de « SPOF eregion ».** D-82, D-83 et ce paragraphe affirmaient que `eregion` alimente la forge qui fait autorité (« le poste y pousse, la forge du site en tire »). Le chemin mesuré dit autre chose : `make genome-pousser` porte les commits du poste en bundle au runner du site, qui pousse sur sa forge — `eregion` n'y figure pas. C'est une forge héritée (`forge.alliance-boreale.ca`), porte publique des contributions, qui ne fera jamais partie de Set-OPS ; la redondance vient d'une forge par site, chacune inséminée du génome. La phrase avait été recopiée trois fois sans que personne ne suive le chemin — moi compris, la veille. **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, et corrigé le 2026-09-28.** `make sdn-plan`, cluster injoignable, annonçait « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Une lecture en échec rend `{'_erreur': …}`, et `rep or []` parcourait ce dict comme une liste vide. `appliquer_sdn.py` s'arrête désormais, comme `frontiere-plan`. Et `t29` rejoint `ANCIEN_NOMMAGE` : sortie du devis sans cela, la zone aurait été lue comme étrangère et laissée en place, avec ses VLAN 1291-1296. `devis_proxmox_fw.py` et `devis_proxmox_pools.py` balayaient encore TOUS les dossiers frères (`decouvrir()`), alors que la frontière et le SDN s'en tiennent à `underlay.tenants` (`decouvrir_du_site()`, dont la docstring le demandait déjà pour « les devis qui équipent un site »). Le runner du site, qui gardait un clone de patient 0, proposait donc de recréer ses groupes ; le poste comptait un dossier de CI comme tenant. Les deux devis suivent maintenant la même liste : 2 tenants, et non 3. Filtré ainsi, le devis ne voyait plus du tout `t29` — ni à créer, ni à RETIRER : son périmètre ne retient que les préfixes des tenants présents. 3 IPSets et 6 groupes `t29-` restaient posés sur le cluster. `appliquer_proxmox_fw.py` ajoute à son périmètre les étiquettes des zones de `ANCIEN_NOMMAGE` (une liste, pas deux), et reçoit le même garde que le SDN contre un cluster muet. **Posé le 2026-09-28, depuis le runner du site.** SDN : zone `t29`, 3 VNets, 3 sous-réseaux retirés, le plan relu dit « Rien à faire ». Frontière : 44 objets `PATI29` et 3 routes `10.29.x` retirés, 283 règles inchangées, rien d'autre au plan relu. Pare-feu Proxmox : les 6 groupes et 3 IPSets `t29-` retirés un par un par l'API — PAS par `proxmox-fw-appliquer`, dont le plan mêle 64 changements préexistants sur `t17`/`t23` qui demandent leur propre revue. Ce retrait a révélé un dernier défaut : l'applicateur envoyait `force` dans le CORPS d'un `DELETE`, que Proxmox refuse (501) ; aucun IPSet n'était donc jamais retiré. Corrigé (`?force=1`). La strophe FRR des nœuds de sortie garde `t29` : ils sont injoignables en SSH depuis le runner. Wiki republié (P60) : deux de ses pages nommaient patient 0. `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 `