# CHANGELOG — Set-OPS ## 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`. **Ce que ça révèle au passage.** Le site ne déclare `journaux-frontiere` nulle part : aucune machine ne sonde aujourd'hui les journaux de la frontière — la panne de dix jours qu'elle devait empêcher n'est pas couverte. ## 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 `